---
title: "Low-Level x402 Signing Primitives"
canonical: "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"
publisher: "Primitive SDKs"
kind: "guide"
content_type: "reference"
category: "Node.js SDK"
parent: "x402-payments-overview-04a296ff"
description: "deriveEip3009Nonce, computePaymentValidityWindow, signInteractionPayment, and buildExactEvmPaymentPayload let you sign an x402 payment outside pay()."
keywords: ["deriveEip3009Nonce", "computePaymentValidityWindow", "signInteractionPayment", "buildExactEvmPaymentPayload", "x402 payment challenge", "EIP-3009 nonce derivation"]
last_modified: "2026-08-21T18:22:43.359885+00:00"
published_at: "2026-08-11T18:54:57.115735+00:00"
source_files:
  - "sdk-node/src/x402/sign.ts"
  - "sdk-node/README.md"
sections:
  - {anchor: "what-each-primitive-does", title: "What each primitive does"}
  - {anchor: "derive-the-interaction-bound-nonce", title: "Derive the interaction-bound nonce"}
  - {anchor: "compute-the-validity-window", title: "Compute the validity window"}
  - {anchor: "sign-the-interaction-bound-payment", title: "Sign the interaction-bound payment"}
  - {anchor: "step-prepare-the-signer-domain-and-amount", title: "Prepare the signer, domain, and amount"}
  - {anchor: "step-compute-the-validity-window", title: "Compute the validity window"}
  - {anchor: "step-sign-the-interaction-bound-authorization", title: "Sign the interaction-bound authorization"}
  - {anchor: "step-assemble-the-wire-payload", title: "Assemble the wire payload"}
  - {anchor: "expected-shape", title: "Expected shape"}
  - {anchor: "networks-and-chain-ids", title: "Networks and chain IDs"}
  - {anchor: "next-steps", title: "Next steps"}
---

> Documentation index: https://test.abhinandan.one/llms.txt

# 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()`](https://test.abhinandan.one/x402-payments-overview-04a296ff/node-sdk-x402-paying.md) 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](https://test.abhinandan.one/x402-payments-overview.md) 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](https://test.abhinandan.one/x402-payments-overview-04a296ff/node-sdk-x402-email.md).

## Derive the interaction-bound nonce

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

```text
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.

```typescript
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 `bigint`s, 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.

```typescript
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.

```typescript
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. 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`).

```typescript
import { privateKeyToAccount } from "viem/accounts";

const payer = privateKeyToAccount(process.env.PAYER_KEY as `0x${string}`);
const pr = challenge.payment_requirements;
```

### 2. Compute the validity window

Use `computePaymentValidityWindow` from the previous section.

```typescript
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

`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.

```typescript
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. 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.

```typescript
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:

```json
{
  "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.
