Documentation Index: Fetch llms.txt first to discover every published page. This page is also available as Markdown at /x402-payments-overview-04a296ff/node-sdk-x402-signing-primitives.md.
Verified · 8/11/2026

Low-Level x402 Signing Primitives

Drive EIP-3009 nonce derivation, validity-window computation, and payment payload assembly yourself, for signing flows that pay() can't cover.

Drive EIP-3009 nonce derivation, validity-window computation, signing, and wire-payload assembly yourself with four functions exported from @primitivedotdev/sdk/x402. Reach for them when pay() doesn't fit your signing flow, for example when driving a hardware wallet, a remote KMS, or a custom submission path instead of an in-process key. All four are pure: no network I/O, no side effects.

Tip

For the default flow, use pay() with a viem LocalAccount, which calls these same primitives internally. Drop to this page only when pay() cannot sign the way you need.

What each primitive does#

FunctionPurpose
deriveEip3009Nonce(binding)Derives the interaction-bound EIP-3009 nonce from a challenge's nonce_binding.
computePaymentValidityWindow(input)Computes the { validAfter, validBefore } window, clamped into the band the platform accepts.
signInteractionPayment(input)Derives the nonce, assembles the EIP-3009 authorization, and signs it with your callback.
buildExactEvmPaymentPayload(input)Assembles and validates the exact-EVM x402 wire payload from a signed authorization.

Every x402 payment challenge carries payment_requirements and a nonce_binding; these primitives consume those fields directly, whether the challenge came from charge() or from the email-native flow.

Derive the interaction-bound nonce#

deriveEip3009Nonce(binding) returns the 32-byte, hex-encoded EIP-3009 nonce bound to one challenge step, computed as:

keccak256( utf8(lower(interaction_id)) || 0x00
         || utf8(lower(challenge_step_id)) || 0x00
         || hexdecode(challenge_nonce) )

The 0x00 separators pin the field boundaries so undelimited concatenation of variable-length strings can't collide. The platform recomputes this exact byte layout and rejects a mismatch, so don't alter the derivation.

import { deriveEip3009Nonce } from "@primitivedotdev/sdk/x402";

const nonce = deriveEip3009Nonce({
  interactionId: challenge.nonce_binding.interaction_id,
  challengeStepId: challenge.nonce_binding.challenge_step_id,
  challengeNonce: challenge.nonce_binding.challenge_nonce,
});
// nonce: "0x..." (32 bytes, hex-encoded)

challengeNonce must be exactly 64 lowercase hex characters (32 bytes, no 0x prefix) or the call throws.

Compute the validity window#

computePaymentValidityWindow returns { validAfter, validBefore } as bigints, landing validBefore inside the band the platform accepts by default:

  • validBefore keeps at least a 60-second minimum settlement headroom past now, so a near-expired challenge isn't signed into a guaranteed rejection.
  • The total window (validBefore - validAfter) is clamped to a 24-hour cap, so a far-future expiry never produces an "authorization window too wide" rejection.
import { computePaymentValidityWindow } from "@primitivedotdev/sdk/x402";

const { validAfter, validBefore } = computePaymentValidityWindow({
  challengeExpiresAtSec: Math.floor(Date.parse(challenge.expires_at) / 1000),
  nowSec: Math.floor(Date.now() / 1000),
});

By default the function clamps a computed or unset window into the accepted band, so a caller who passes only challengeExpiresAtSec and nowSec always gets a signable window back.

Pass an explicit validBeforeSec / validAfterSec to pin a bound. With clamp: false, an out-of-band pinned value throws a specific error naming which bound was violated, instead of silently signing a doomed authorization.

import { computePaymentValidityWindow } from "@primitivedotdev/sdk/x402";

try {
  computePaymentValidityWindow({
    challengeExpiresAtSec: Math.floor(Date.parse(challenge.expires_at) / 1000),
    nowSec: Math.floor(Date.now() / 1000),
    validBeforeSec: pinnedValidBefore,
    clamp: false,
  });
} catch (err) {
  // err.message names the violated bound, e.g. "validBefore ... is below
  // the minimum settlement headroom" or "... exceeds the ... window cap"
}
Warning

Never hand-set validBefore without running it through this function first. A window outside the accepted band is rejected by the platform, and a too-wide window leaves a signed, settleable authorization outstanding for longer than necessary.

Sign the interaction-bound payment#

  1. 1

    Prepare the signer, domain, and amount#

    You need:

    • A signer exposing signTypedData (a viem LocalAccount from privateKeyToAccount works directly).
    • The TokenDomain (name, version, chainId, verifyingContract), taken from the challenge's payment_requirements.extra and payment_requirements.asset. A wrong name or version produces a signature the verifier rejects.
    • The amount in token base units, as a bigint (USDC has 6 decimals, so 0.01 USDC is 10000n).
    import { privateKeyToAccount } from "viem/accounts";
    
    const payer = privateKeyToAccount(process.env.PAYER_KEY as `0x${string}`);
    const pr = challenge.payment_requirements;
    
  2. 2

    Compute the validity window#

    Use computePaymentValidityWindow from the previous section.

    import { computePaymentValidityWindow } from "@primitivedotdev/sdk/x402";
    
    const nowSec = Math.floor(Date.now() / 1000);
    const { validAfter, validBefore } = computePaymentValidityWindow({
      challengeExpiresAtSec: Math.floor(Date.parse(challenge.expires_at) / 1000),
      nowSec,
    });
    
  3. 3

    Sign the interaction-bound authorization#

    signInteractionPayment derives the bound nonce, assembles the EIP-3009 authorization, and signs it with your callback in one call, returning { authorization, signature }. This is the one piece a stock x402 signer can't do on its own: the nonce is interaction-bound, not generated internally.

    import { signInteractionPayment } from "@primitivedotdev/sdk/x402";
    
    const { authorization, signature } = await signInteractionPayment({
      sign: (typedData) => payer.signTypedData(typedData),
      payer: payer.address,
      domain: {
        name: pr.extra.name,
        version: pr.extra.version,
        chainId: 84532, // base-sepolia
        verifyingContract: pr.asset as `0x${string}`,
      },
      payTo: pr.payTo as `0x${string}`,
      amount: BigInt(pr.maxAmountRequired),
      nonceBinding: {
        interactionId: challenge.nonce_binding.interaction_id,
        challengeStepId: challenge.nonce_binding.challenge_step_id,
        challengeNonce: challenge.nonce_binding.challenge_nonce,
      },
      validAfter,
      validBefore,
    });
    

    The signer's key never leaves your process; sign is your callback, not a value handed to the SDK.

  4. 4

    Assemble the wire payload#

    buildExactEvmPaymentPayload wraps the signed authorization in the exact-EVM x402 envelope and validates the nonce and signature shape before you submit anything.

    import { buildExactEvmPaymentPayload } from "@primitivedotdev/sdk/x402";
    
    const payment = buildExactEvmPaymentPayload({
      network: "base-sepolia",
      authorization,
      signature,
    });
    // submit `payment` to POST /v1/x402/challenges/{id}/pay
    

Expected shape#

payment matches the wire schema the platform verifies, with the numeric authorization fields rendered as decimal strings:

{
  "x402Version": 1,
  "scheme": "exact",
  "network": "base-sepolia",
  "payload": {
    "signature": "0x...",
    "authorization": {
      "from": "0x...",
      "to": "0x...",
      "value": "10000",
      "validAfter": "<unix-seconds>",
      "validBefore": "<unix-seconds>",
      "nonce": "0x..."
    }
  }
}

buildExactEvmPaymentPayload throws if the network isn't base or base-sepolia, if the signature isn't a 0x-prefixed 65-byte (130 hex char) EIP signature, or if the nonce isn't a 0x-prefixed 32-byte (64 hex char) value, catching a malformed payload before it reaches the server.

Networks and chain IDs#

NetworkChain ID
base-sepolia84532
base8453

Pass the network name as a plain string ("base-sepolia" or "base") to buildExactEvmPaymentPayload; pass the numeric chain ID separately to the TokenDomain you build for signing.

Next steps#

Was this page helpful?

© Primitive SDKs

Powered by Browzer