{"schema_version":"1.0","publisher":"Primitive SDKs","canonical_url":"https://test.abhinandan.one/x402-payments-overview-04a296ff/node-sdk-x402-signing-primitives","markdown_url":"https://test.abhinandan.one/x402-payments-overview-04a296ff/node-sdk-x402-signing-primitives.md","article":{"id":"3a13481d-37f2-425c-9519-e7c5a1d876e4","article_slug":"node-sdk-x402-signing-primitives","parent_article_slug":"x402-payments-overview-04a296ff","parent_article_title":"x402 Payments Overview","kind":"guide","published_at":"2026-08-11T18:54:57.115735+00:00","keywords":["deriveEip3009Nonce","computePaymentValidityWindow","signInteractionPayment","buildExactEvmPaymentPayload","x402 payment challenge","EIP-3009 nonce derivation"],"meta_description":"deriveEip3009Nonce, computePaymentValidityWindow, signInteractionPayment, and buildExactEvmPaymentPayload let you sign an x402 payment outside pay().","og_image_url":null,"source_file_paths":["sdk-node/src/x402/sign.ts","sdk-node/README.md"],"recording_id":null,"replayable":false,"task_name":"Low-Level x402 Signing Primitives","category":"Node.js SDK","summary":null,"description":"Drive EIP-3009 nonce derivation, validity-window computation, and payment payload assembly yourself, for signing flows that pay() can't cover.","content_kind":"repo_page","content_markdown":"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.\n\n<Tip>\n\nFor the default flow, use [`pay()`](node-sdk-x402-paying) with a viem `LocalAccount`, which calls these same primitives internally. Drop to this page only when `pay()` cannot sign the way you need.\n\n</Tip>\n\n## What each primitive does\n\n| Function | Purpose |\n| --- | --- |\n| `deriveEip3009Nonce(binding)` | Derives the interaction-bound EIP-3009 nonce from a challenge's `nonce_binding`. |\n| `computePaymentValidityWindow(input)` | Computes the `{ validAfter, validBefore }` window, clamped into the band the platform accepts. |\n| `signInteractionPayment(input)` | Derives the nonce, assembles the EIP-3009 authorization, and signs it with your callback. |\n| `buildExactEvmPaymentPayload(input)` | Assembles and validates the exact-EVM x402 wire payload from a signed authorization. |\n\nEvery x402 [payment challenge](x402-payments-overview) 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](node-sdk-x402-email).\n\n## Derive the interaction-bound nonce\n\n`deriveEip3009Nonce(binding)` returns the 32-byte, hex-encoded EIP-3009 nonce bound to one challenge step, computed as:\n\n```text\nkeccak256( utf8(lower(interaction_id)) || 0x00\n         || utf8(lower(challenge_step_id)) || 0x00\n         || hexdecode(challenge_nonce) )\n```\n\nThe `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.\n\n```typescript\nimport { deriveEip3009Nonce } from \"@primitivedotdev/sdk/x402\";\n\nconst nonce = deriveEip3009Nonce({\n  interactionId: challenge.nonce_binding.interaction_id,\n  challengeStepId: challenge.nonce_binding.challenge_step_id,\n  challengeNonce: challenge.nonce_binding.challenge_nonce,\n});\n// nonce: \"0x...\" (32 bytes, hex-encoded)\n```\n\n`challengeNonce` must be exactly 64 lowercase hex characters (32 bytes, no `0x` prefix) or the call throws.\n\n## Compute the validity window\n\n`computePaymentValidityWindow` returns `{ validAfter, validBefore }` as `bigint`s, landing `validBefore` inside the band the platform accepts by default:\n\n- `validBefore` keeps at least a **60-second** minimum settlement headroom past now, so a near-expired challenge isn't signed into a guaranteed rejection.\n- 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.\n\n```typescript\nimport { computePaymentValidityWindow } from \"@primitivedotdev/sdk/x402\";\n\nconst { validAfter, validBefore } = computePaymentValidityWindow({\n  challengeExpiresAtSec: Math.floor(Date.parse(challenge.expires_at) / 1000),\n  nowSec: Math.floor(Date.now() / 1000),\n});\n```\n\nBy 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.\n\nPass 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.\n\n```typescript\nimport { computePaymentValidityWindow } from \"@primitivedotdev/sdk/x402\";\n\ntry {\n  computePaymentValidityWindow({\n    challengeExpiresAtSec: Math.floor(Date.parse(challenge.expires_at) / 1000),\n    nowSec: Math.floor(Date.now() / 1000),\n    validBeforeSec: pinnedValidBefore,\n    clamp: false,\n  });\n} catch (err) {\n  // err.message names the violated bound, e.g. \"validBefore ... is below\n  // the minimum settlement headroom\" or \"... exceeds the ... window cap\"\n}\n```\n\n<Warning>\n\nNever 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.\n\n</Warning>\n\n## Sign the interaction-bound payment\n\n<Steps>\n\n<Step title=\"Prepare the signer, domain, and amount\">\n\nYou need:\n\n- A signer exposing `signTypedData` (a viem `LocalAccount` from `privateKeyToAccount` works directly).\n- 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.\n- The amount in token base units, as a `bigint` (USDC has 6 decimals, so `0.01` USDC is `10000n`).\n\n```typescript\nimport { privateKeyToAccount } from \"viem/accounts\";\n\nconst payer = privateKeyToAccount(process.env.PAYER_KEY as `0x${string}`);\nconst pr = challenge.payment_requirements;\n```\n\n</Step>\n\n<Step title=\"Compute the validity window\">\n\nUse `computePaymentValidityWindow` from the previous section.\n\n```typescript\nimport { computePaymentValidityWindow } from \"@primitivedotdev/sdk/x402\";\n\nconst nowSec = Math.floor(Date.now() / 1000);\nconst { validAfter, validBefore } = computePaymentValidityWindow({\n  challengeExpiresAtSec: Math.floor(Date.parse(challenge.expires_at) / 1000),\n  nowSec,\n});\n```\n\n</Step>\n\n<Step title=\"Sign the interaction-bound authorization\">\n\n`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.\n\n```typescript\nimport { signInteractionPayment } from \"@primitivedotdev/sdk/x402\";\n\nconst { authorization, signature } = await signInteractionPayment({\n  sign: (typedData) => payer.signTypedData(typedData),\n  payer: payer.address,\n  domain: {\n    name: pr.extra.name,\n    version: pr.extra.version,\n    chainId: 84532, // base-sepolia\n    verifyingContract: pr.asset as `0x${string}`,\n  },\n  payTo: pr.payTo as `0x${string}`,\n  amount: BigInt(pr.maxAmountRequired),\n  nonceBinding: {\n    interactionId: challenge.nonce_binding.interaction_id,\n    challengeStepId: challenge.nonce_binding.challenge_step_id,\n    challengeNonce: challenge.nonce_binding.challenge_nonce,\n  },\n  validAfter,\n  validBefore,\n});\n```\n\nThe signer's key never leaves your process; `sign` is your callback, not a value handed to the SDK.\n\n</Step>\n\n<Step title=\"Assemble the wire payload\">\n\n`buildExactEvmPaymentPayload` wraps the signed authorization in the exact-EVM x402 envelope and validates the nonce and signature shape before you submit anything.\n\n```typescript\nimport { buildExactEvmPaymentPayload } from \"@primitivedotdev/sdk/x402\";\n\nconst payment = buildExactEvmPaymentPayload({\n  network: \"base-sepolia\",\n  authorization,\n  signature,\n});\n// submit `payment` to POST /v1/x402/challenges/{id}/pay\n```\n\n</Step>\n\n</Steps>\n\n### Expected shape\n\n`payment` matches the wire schema the platform verifies, with the numeric authorization fields rendered as decimal strings:\n\n```json\n{\n  \"x402Version\": 1,\n  \"scheme\": \"exact\",\n  \"network\": \"base-sepolia\",\n  \"payload\": {\n    \"signature\": \"0x...\",\n    \"authorization\": {\n      \"from\": \"0x...\",\n      \"to\": \"0x...\",\n      \"value\": \"10000\",\n      \"validAfter\": \"<unix-seconds>\",\n      \"validBefore\": \"<unix-seconds>\",\n      \"nonce\": \"0x...\"\n    }\n  }\n}\n```\n\n`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.\n\n## Networks and chain IDs\n\n| Network | Chain ID |\n| --- | --- |\n| `base-sepolia` | `84532` |\n| `base` | `8453` |\n\nPass 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.\n\n## Next steps\n\n<CardGroup cols={2}>\n\n<Card title=\"Paying a Challenge\" href=\"node-sdk-x402-paying\">\n\nUse the high-level pay() flow that wraps these primitives for the common case.\n\n</Card>\n\n<Card title=\"Email-Native x402 Payments\" href=\"node-sdk-x402-email\">\n\nSign a challenge received as an interaction.json email attachment instead of a synthetic challenge id.\n\n</Card>\n\n<Card title=\"x402 Payments Overview\" href=\"x402-payments-overview\">\n\nReview the full payout registration, charge, pay, and spend-policy model.\n\n</Card>\n\n<Card title=\"Node.js SDK Errors\" href=\"node-sdk-errors\">\n\nLook up X402Error status codes and what triggers each one.\n\n</Card>\n\n</CardGroup>","canonical_base_url":"https://test.abhinandan.one","seo_indexing_enabled":true,"last_modified":"2026-08-21T18:22:43.359885+00:00","video_url":null,"voiceover_url":null,"tools_used":[],"demonstrated_by":[],"steps":[],"related_links":[],"intro":null,"prerequisites":[],"verification":[],"troubleshooting":[],"suggest_edit_url":"https://github.com/abhi-browzer/primitive-sdks/edit/main/sdk-node/src/x402/sign.ts","raise_issue_url":"https://github.com/abhi-browzer/primitive-sdks/issues/new?title=Docs+feedback%3A+Low-Level+x402+Signing+Primitives&body=Page%3A+https%3A%2F%2Ftest.abhinandan.one%2Fnode-sdk-x402-signing-primitives","page_feedback_enabled":true,"verified_ref":null,"verified_at":"2026-08-11T18:38:45.205849+00:00"}}