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.
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#
| Function | Purpose |
|---|---|
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:
validBeforekeeps 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"
}
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
Prepare the signer, domain, and amount#
You need:
- A signer exposing
signTypedData(a viemLocalAccountfromprivateKeyToAccountworks directly). - The
TokenDomain(name,version,chainId,verifyingContract), taken from the challenge'spayment_requirements.extraandpayment_requirements.asset. A wrongnameorversionproduces a signature the verifier rejects. - The amount in token base units, as a
bigint(USDC has 6 decimals, so0.01USDC is10000n).
import { privateKeyToAccount } from "viem/accounts"; const payer = privateKeyToAccount(process.env.PAYER_KEY as `0x${string}`); const pr = challenge.payment_requirements; - A signer exposing
- 2
Compute the validity window#
Use
computePaymentValidityWindowfrom 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
Sign the interaction-bound authorization#
signInteractionPaymentderives 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;
signis your callback, not a value handed to the SDK. - 4
Assemble the wire payload#
buildExactEvmPaymentPayloadwraps 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#
| Network | Chain ID |
|---|---|
base-sepolia | 84532 |
base | 8453 |
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#
Use the high-level pay() flow that wraps these primitives for the common case.
Email-Native x402 PaymentsSign a challenge received as an interaction.json email attachment instead of a synthetic challenge id.
x402 Payments OverviewReview the full payout registration, charge, pay, and spend-policy model.
Node.js SDK ErrorsLook up X402Error status codes and what triggers each one.
Was this page helpful?