x402 Signing Primitives (Go)
Reference for the low-level Go building blocks behind Pay, DeriveEIP3009Nonce, ComputePaymentValidityWindow, SignInteractionPayment, and BuildExactEvmPaymentPayload, for callers who need to drive x402 signing themselves.
These are the building blocks Client.Pay uses internally to sign an x402 payment challenge. Reach for them directly only when Pay doesn't fit your signing flow, for example signing a challenge carried in an email reply and submitting the payment separately, or driving a hardware wallet or remote KMS instead of an in-process key. For the default flow, use Creating a Payment Challenge and Pay as documented in x402 Payments Overview.
All four functions live in package primitive (module github.com/primitivedotdev/sdks/sdk-go). None of them perform I/O: they derive, assemble, and sign locally, and never send anything over the wire.
DeriveEIP3009Nonce#
DeriveEIP3009Nonce derives the EIP-3009 nonce bound to a specific interaction step. The platform recomputes this exact value and rejects a payment whose nonce doesn't match.
func DeriveEIP3009Nonce(input NonceBinding) (string, error)
The byte layout is locked to a normative test vector the platform verifier also computes, and MUST NOT change:
keccak256( utf8(lower(interaction_id)) || 0x00
|| utf8(lower(challenge_step_id)) || 0x00
|| hexdecode(challenge_nonce) )
The 0x00 separators pin the field boundaries; undelimited concatenation of variable-length strings is collision-ambiguous. The challenge nonce is decoded to its 32 raw bytes before hashing. Returns the 0x-prefixed 32-byte hash.
NonceBinding#
| Field | Type | Description |
|---|---|---|
InteractionID | string | The interaction id, including its @domain. Lowercased before hashing. |
ChallengeStepID | string | The challenge step id (a UUID). Lowercased before hashing. |
ChallengeNonce | string | The challenger's per-challenge random nonce: exactly 64 lowercase hex chars, no 0x prefix. |
// main.go
package main
import (
primitive "github.com/primitivedotdev/sdks/sdk-go"
)
nonce, err := primitive.DeriveEIP3009Nonce(primitive.NonceBinding{
InteractionID: "a1b2c3d4-0000-0000-0000-000000000001@payer.example",
ChallengeStepID: "f00dface-0000-0000-0000-0000000000aa",
ChallengeNonce: "aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899",
})
// nonce == "0xc955a08812ab83f9e25c92e5162267b913957c3cc8678de1cf1449f77b516c6e"
Case is normalized before hashing: uppercasing InteractionID and ChallengeStepID produces the identical nonce. ChallengeNonce itself, however, must already be lowercase hex, or DeriveEIP3009Nonce returns an error.
Errors: returns an error if ChallengeNonce is not exactly 64 lowercase hex characters, or is not valid hex.
ComputePaymentValidityWindow#
ComputePaymentValidityWindow computes the EIP-3009 (validAfter, validBefore) window for a payment, landing it inside the band the platform accepts.
func ComputePaymentValidityWindow(input ValidityWindowInput) (validAfter, validBefore *big.Int, err error)
validBefore governs on-chain validity: it must stay far enough in the future to settle, yet not so far that the total window exceeds the cap. validAfter is set generously in the past to absorb clock skew. By default the function clamps the computed window into the accepted band so a caller who doesn't override anything always gets a signable window.
ValidityWindowInput#
| Field | Type | Default | Description |
|---|---|---|---|
ChallengeExpiresAtSec | int64 | required | The challenge's expires_at, unix seconds. |
NowSec | int64 | required | Current time, unix seconds. |
SettlementMarginSec | int64 | 300 (5 min) | Headroom past expiry for verify+settle to complete. |
ClockSkewSec | int64 | 300 (5 min) | How far in the past to set validAfter. |
MaxWindowSec | int64 | DefaultMaxWindowSec (24h) | Hard ceiling on validBefore - validAfter. |
MinHeadroomSec | int64 | DefaultMinSettlementHeadroomSec (60s) | Minimum validBefore - NowSec. |
ValidBeforeSec | *int64 | derived | Pin validBefore. When nil, derived as ChallengeExpiresAtSec + SettlementMarginSec. |
ValidAfterSec | *int64 | derived | Pin validAfter. When nil, derived as NowSec - ClockSkewSec. |
Clamp | *bool | true (nil = true) | When true, an out-of-band window is clamped into the accepted band instead of erroring. |
A signed EIP-3009 authorization stays settleable on-chain until validBefore regardless of interaction state, so an unbounded window is a standing "funds committed" risk; DefaultMaxWindowSec (24 hours) is the hard safety ceiling. DefaultMinSettlementHeadroomSec (60 seconds) is the floor below which the platform rejects a payment as "about to expire," because it needs SMTP + DKIM + verify + settle latency to clear.
Clamping behavior:
- Default (no pinned bounds, or
Clampleftnil/true): a computed window outside[floor, ceiling]is silently clamped to fit. - Pinned +
Clamp: false: aValidBeforeSecoutside the band returns a specific error naming which bound was violated ("about to expire" vs. "authorization window too wide") instead of signing a doomed authorization. - Pinned +
Clampunset ortrue: the pinned value is clamped like the computed one.
// main.go
import (
"log"
"time"
primitive "github.com/primitivedotdev/sdks/sdk-go"
)
validAfter, validBefore, err := primitive.ComputePaymentValidityWindow(primitive.ValidityWindowInput{
ChallengeExpiresAtSec: challengeExpiresAtUnix,
NowSec: time.Now().Unix(),
})
if err != nil {
log.Fatal(err)
}
Pinning with clamp disabled, to catch an out-of-band caller value instead of silently moving it:
clamp := false
vbPin := time.Now().Unix() + 5 // only 5s headroom, below the 60s floor
_, _, err := primitive.ComputePaymentValidityWindow(primitive.ValidityWindowInput{
ChallengeExpiresAtSec: challengeExpiresAtUnix,
NowSec: time.Now().Unix(),
ValidBeforeSec: &vbPin,
Clamp: &clamp,
})
// err: "validBefore (...) is below the minimum settlement headroom ...
// the authorization would be rejected as about to expire"
Errors: returns an error when MaxWindowSec < MinHeadroomSec (invalid config), when a pinned ValidBeforeSec falls outside the accepted band with Clamp: false, or when the resulting validBefore would not be after validAfter (typically an already-expired challenge or a pinned ValidAfterSec set too late).
SignInteractionPayment#
SignInteractionPayment derives the bound nonce, assembles the EIP-3009 authorization, and signs it with your Sign callback. It is the one step a stock x402 signer can't do on its own, since such signers generate the nonce internally with no injection point.
func SignInteractionPayment(input SignInteractionPaymentInput) (TransferAuthorization, string, error)
SignInteractionPaymentInput#
| Field | Type | Description |
|---|---|---|
Sign | func(apitypes.TypedData) (string, error) | Signs EIP-712 typed data with the caller's own key (e.g. PrivateKeySigner.SignTypedData). The key never leaves the caller. |
Payer | string | The from address. |
Domain | TokenDomain | The token's EIP-712 domain (see below). |
PayTo | string | The recipient, the challenger's payTo. |
Amount | *big.Int | Amount in token base units. |
NonceBinding | NonceBinding | Same shape as for DeriveEIP3009Nonce. |
ValidAfter | *big.Int | From ComputePaymentValidityWindow. |
ValidBefore | *big.Int | From ComputePaymentValidityWindow. |
TokenDomain#
| Field | Type | Description |
|---|---|---|
Name | string | The token's EIP-712 domain name. Take it from the challenge's payment_requirements.extra; a wrong value produces a signature the verifier rejects. |
Version | string | The token's EIP-712 domain version, same source as Name. |
ChainID | int64 | The EVM chain id (84532 for base-sepolia, 8453 for base). |
VerifyingContract | string | The token contract address, from payment_requirements.asset. |
Returns the TransferAuthorization it built plus the hex signature string.
pr := challenge.PaymentRequirements
auth, signature, err := primitive.SignInteractionPayment(primitive.SignInteractionPaymentInput{
Sign: payer.SignTypedData,
Payer: payer.Address(),
Domain: primitive.TokenDomain{
Name: pr.Extra.Name,
Version: pr.Extra.Version,
ChainID: 84532, // base-sepolia
VerifyingContract: pr.Asset,
},
PayTo: pr.PayTo,
Amount: amount,
NonceBinding: primitive.NonceBinding{
InteractionID: challenge.NonceBinding.InteractionID,
ChallengeStepID: challenge.NonceBinding.ChallengeStepID,
ChallengeNonce: challenge.NonceBinding.ChallengeNonce,
},
ValidAfter: validAfter,
ValidBefore: validBefore,
})
Errors: propagates a DeriveEIP3009Nonce error for a malformed NonceBinding, or any error your Sign callback returns.
BuildExactEvmPaymentPayload#
BuildExactEvmPaymentPayload assembles and validates the exact-EVM x402 wire payload from a signed authorization: the JSON body submitted to POST /v1/x402/challenges/{id}/pay.
func BuildExactEvmPaymentPayload(network string, auth TransferAuthorization, signature string) (X402PaymentPayload, error)
| Parameter | Type | Description |
|---|---|---|
network | string | Must be "base" or "base-sepolia". |
auth | TransferAuthorization | The authorization returned by SignInteractionPayment (or assembled by hand). |
signature | string | The 0x-prefixed 65-byte (130 hex char) EIP signature. |
The numeric authorization fields (Value, ValidAfter, ValidBefore) are *big.Int in Go but decimal strings on the wire; this function stringifies them. The nonce passes through as hex. Validation rejects a malformed nonce or signature loudly rather than emitting a payload the platform will reject.
payment, err := primitive.BuildExactEvmPaymentPayload(challenge.Network, auth, signature)
if err != nil {
log.Fatal(err)
}
// submit `payment` to /v1/x402/challenges/{id}/pay
Errors: returns an error for an unsupported network (anything other than "base" / "base-sepolia"), a signature that isn't a 0x-prefixed 130-hex-char string, or an auth.Nonce that isn't a 0x-prefixed 64-hex-char string.
TransferAuthorization#
TransferAuthorization is the EIP-3009 TransferWithAuthorization message assembled by SignInteractionPayment and consumed by BuildExactEvmPaymentPayload.
| Field | Type | Description |
|---|---|---|
From | string | The payer's address. |
To | string | The recipient's address (payTo). |
Value | *big.Int | Amount in token base units. |
ValidAfter | *big.Int | Start of the validity window, unix seconds. |
ValidBefore | *big.Int | End of the validity window, unix seconds. |
Nonce | string | The 0x-prefixed 32-byte interaction-bound nonce. |
X402Signer and PrivateKeySigner#
X402Signer is the caller-held signer interface these primitives sign through; PrivateKeySigner is the built-in implementation backed by an in-memory secp256k1 key. The interface is:
type X402Signer interface {
Address() string
SignTypedData(typedData apitypes.TypedData) (string, error)
SignMessage(message string) (string, error)
}
PrivateKeySigner, built with NewPrivateKeySigner(hexKey string), implements this directly from an in-memory secp256k1 key (with or without the 0x prefix). The key stays in process memory and is never sent to Primitive. SignMessage (EIP-191 personal_sign) is only needed for RegisterPayoutAddress's ownership proof; SignTypedData is the EIP-712 signer these primitives use.
// main.go
import (
"log"
"os"
primitive "github.com/primitivedotdev/sdks/sdk-go"
)
payer, err := primitive.NewPrivateKeySigner(os.Getenv("PAYER_KEY"))
if err != nil {
log.Fatal(err)
}
Adapt a hardware wallet or remote KMS by implementing the same three-method interface.
BuildPayoutRegistrationMessage#
BuildPayoutRegistrationMessage builds the payout-address ownership message signed with SignMessage during payout registration. It is documented here because it's another no-I/O building block alongside the signing primitives above.
func BuildPayoutRegistrationMessage(org, address, network, issuedAt string) string
This MUST be byte-identical to the platform's own message construction, or registration fails the ownership proof. The org id is embedded in the signed bytes, so a captured signature can never register the address under a different org. address is lowercased in the message regardless of input casing.
Next steps#
The full non-custodial payment model: registering payouts, charging, paying, and spend policy.
Creating a Payment ChallengeCreate a payment challenge with Client.Charge, the high-level entry point these primitives sit underneath.
x402 ErrorsInterpret X402Error status codes and retry-after headers when a payment request fails.
Registering a Payout AddressProve control of a wallet and register it as your default x402 payout destination.
Was this page helpful?