Low-Level Payment Signing
Reference for the individual signing primitives, nonce derivation, validity window computation, and EIP-712 signing, that `pay()` normally composes for you, for callers who need to drive the x402 signing flow themselves.
When to use these instead of pay()#
Use the low-level signing primitives only when X402Client.pay(), which derives the nonce, builds the authorization, signs it, and submits it in one call, doesn't fit your signing flow. That means driving a hardware wallet, submitting the payment through a different channel than the SDK's HTTP call, or signing a challenge carried in an email reply before sending it separately (see Email-Native Payments).
All four functions are pure: no network I/O, no side effects. They are importable from the primitive package root.
derive_eip3009_nonce#
Derives the EIP-3009 nonce bound to a specific interaction step. The platform recomputes this exact value server-side and rejects a payment whose nonce doesn't match, so the byte layout below is load-bearing and must not be reimplemented differently.
from primitive import NonceBinding, derive_eip3009_nonce
nonce = derive_eip3009_nonce(
NonceBinding(
interaction_id=challenge.nonce_binding["interaction_id"],
challenge_step_id=challenge.nonce_binding["challenge_step_id"],
challenge_nonce=challenge.nonce_binding["challenge_nonce"],
)
)
Byte layout:
keccak256( utf8(lower(interaction_id)) || 0x00
|| utf8(lower(challenge_step_id)) || 0x00
|| hexdecode(challenge_nonce) )
The 0x00 separators pin the field boundaries, because undelimited concatenation of variable-length strings is collision-ambiguous. The challenge nonce is decoded to its 32 raw bytes before hashing.
| Field | Type | Required | Description |
|---|---|---|---|
interaction_id | str | Yes | The interaction id, including its @domain. Lowercased before hashing. |
challenge_step_id | str | Yes | The challenge step id (a UUID). Lowercased before hashing. |
challenge_nonce | str | Yes | The challenger's per-challenge random nonce: 64 lowercase hex chars, no 0x prefix. |
compute_payment_validity_window#
Computes the EIP-3009 (valid_after, valid_before) window for a payment, landing it inside the band the platform accepts.
import math
import time
from dateutil.parser import isoparse
from primitive import compute_payment_validity_window
valid_after, valid_before = compute_payment_validity_window(
challenge_expires_at_sec=math.floor(isoparse(challenge.expires_at).timestamp()),
now_sec=math.floor(time.time()),
)
valid_before governs on-chain validity: it must stay at least 60 seconds in the future so the payment can settle, yet not so far that the total window exceeds the 24-hour cap. valid_after is set generously in the past for clock skew. Both ends are payer landmines: too tight and the platform rejects the authorization as about to expire; too wide and it rejects the window as too wide. By default the function clamps the computed window into the accepted band, so a caller who doesn't override always gets a signable window.
| Parameter | Type | Default | Description |
|---|---|---|---|
challenge_expires_at_sec | int | required | The challenge's expires_at, unix seconds. |
now_sec | int | required | Current time, unix seconds. |
settlement_margin_sec | int | 300 (5 min) | Headroom past expiry for verify + settle to complete; added to challenge_expires_at_sec when valid_before_sec is not pinned. |
clock_skew_sec | int | 300 (5 min) | How far in the past to set valid_after for clock skew, when valid_after_sec is not pinned. |
max_window_sec | int | 86400 (24h) | Hard ceiling on valid_before - valid_after. A signed EIP-3009 authorization stays settleable on-chain until valid_before regardless of interaction state, so this is the safety ceiling against a standing "funds committed" risk. |
valid_before_sec | int | None | derived | Pin valid_before explicitly (unix seconds) instead of deriving it from expiry + margin. |
valid_after_sec | int | None | derived | Pin valid_after explicitly (unix seconds) instead of deriving it from now − skew. |
min_headroom_sec | int | 60 | Minimum valid_before - now_sec; below this the platform rejects the payment as about to expire, because it cannot clear SMTP + DKIM + verify + settle latency. |
clamp | bool | True | When true, an out-of-band window (computed or pinned) is silently clamped into the accepted band. Set False to instead raise a specific error naming which bound was violated. |
Pinning behavior: if you pass valid_before_sec or valid_after_sec explicitly, that's treated as intent to pin the bound. With clamp=False, an out-of-band pinned value raises a specific error naming which bound was violated, instead of silently signing a doomed authorization. With clamp=True (the default), the pinned value is clamped into the band like the computed one.
Returns a (valid_after, valid_before) tuple of unix-second integers.
sign_interaction_payment#
Derives the bound nonce, assembles the EIP-3009 authorization, and signs it with your callback. This is the one piece a stock x402 signer can't do: it generates the nonce internally with no injection point, so this helper exists specifically to let you supply the interaction-bound nonce.
from primitive import (
PrivateKeySigner,
TokenDomain,
NonceBinding,
sign_interaction_payment,
)
payer = PrivateKeySigner(os.environ["PAYER_KEY"])
pr = challenge.payment_requirements
authorization, signature = sign_interaction_payment(
sign=payer.sign_typed_data,
payer=payer.address,
domain=TokenDomain(
name=pr.extra["name"],
version=pr.extra["version"],
chain_id=84532, # base-sepolia
verifying_contract=pr.asset,
),
pay_to=pr.pay_to,
amount=int(pr.max_amount_required),
nonce_binding=NonceBinding(
interaction_id=challenge.nonce_binding["interaction_id"],
challenge_step_id=challenge.nonce_binding["challenge_step_id"],
challenge_nonce=challenge.nonce_binding["challenge_nonce"],
),
valid_after=valid_after,
valid_before=valid_before,
)
| Parameter | Type | Required | Description |
|---|---|---|---|
sign | callable | Yes | Signs EIP-712 typed data with the caller's own key and returns the signature. The key never leaves the caller; pass e.g. PrivateKeySigner.sign_typed_data. |
payer | str | Yes | The payer's (from) address. |
domain | TokenDomain | Yes | The token's EIP-712 domain: name, version, chain_id, verifying_contract. name/version must be the actual token's domain params; take them from challenge.payment_requirements.extra rather than hardcoding them, or the verifier rejects the signature. |
pay_to | str | Yes | The recipient: the challenge's payment_requirements.payTo. |
amount | int | Yes | Amount in token base units. |
nonce_binding | NonceBinding | Yes | Same shape as derive_eip3009_nonce's input; derives the bound nonce internally. |
valid_after | int | Yes | From compute_payment_validity_window. |
valid_before | int | Yes | From compute_payment_validity_window. |
Returns (authorization, signature).
TokenDomain and NonceBinding#
TokenDomain field | Type | Description |
|---|---|---|
name | str | The token's EIP-712 domain name (e.g. "USDC"), from payment_requirements.extra. |
version | str | The token's EIP-712 domain version (e.g. "2"), from payment_requirements.extra. |
chain_id | int | The EVM chain id for the challenge's network (84532 for base-sepolia, 8453 for base). |
verifying_contract | str | The token contract address, the challenge's payment_requirements.asset. |
NonceBinding field | Type | Description |
|---|---|---|
interaction_id | str | The interaction id, including @domain. |
challenge_step_id | str | The challenge step id (a UUID). |
challenge_nonce | str | 64 lowercase hex chars, no 0x prefix. |
build_exact_evm_payment_payload#
Assembles the exact-EVM x402 wire payload from a signed authorization, ready to submit to /v1/x402/challenges/{id}/pay.
from primitive import build_exact_evm_payment_payload
payment = build_exact_evm_payment_payload(
network="base-sepolia",
authorization=authorization,
signature=signature,
).to_dict()
# submit `payment` to /v1/x402/challenges/{id}/pay
| Parameter | Type | Required | Description |
|---|---|---|---|
network | str | Yes | "base" or "base-sepolia". |
authorization | authorization object | Yes | The signed authorization returned by sign_interaction_payment. |
signature | str | Yes | The EIP-712 signature returned by sign_interaction_payment. |
The numeric authorization fields (value, valid_after, valid_before) are serialized as decimal strings on the wire, matching the schema the platform validates against.
Putting it together#
The four primitives compose in this order for a manual signing flow:
sign_interaction_payment calls derive_eip3009_nonce for you, so you only call it directly when you need the nonce on its own. For the standard synthetic-challenge flow this whole sequence is what X402Client.pay does internally; see Creating and Paying Challenges. For the email-carried challenge flow, pay_email_challenge runs the same sequence and returns the signed envelope instead of submitting it; see Email-Native Payments.
Errors#
Every function raises primitive.X402Error (status 0, since no network call is made) on a malformed input, a bad nonce format, an out-of-band pinned validity window with clamp=False, or a malformed signature/network passed to build_exact_evm_payment_payload. See Python SDK Error Reference for the full error catalog.
Next steps#
The high-level charge() / pay() flow these primitives sit underneath.
Email-Native PaymentsIssue and pay an x402 challenge that rides a real email thread.
x402 Payments OverviewThe non-custodial payment model shared across every SDK.
Python SDK Error ReferenceLook up X402Error conditions and suggested fixes.
Was this page helpful?