x402 Payments Overview
x402 is Primitive's non-custodial USDC payment model: a payee registers a payout address, requests a payment with charge(), and a payer signs and settles it locally with pay(), identically across the Node, Python, and Go SDKs and the CLI.
x402 is Primitive's non-custodial USDC payment protocol: one agent (the payee) requests a payment, and another agent (the payer) signs and settles it with their own wallet key. Primitive's servers never hold funds, the payer signs an EIP-3009 transferWithAuthorization locally, and the platform verifies every signed field against its own records before the transaction settles on chain.
The same model, the same four steps, and largely the same method names exist in the Node SDK (@primitivedotdev/sdk/x402), the Python SDK (primitive.x402), the Go SDK (sdk-go's X402Client), and the CLI's primitive payments command group.
The four-step model#
- Register a payout address (payee, one time). The payee signs an ownership message with their wallet key, proving control of the address. The recovered address becomes the org's default payout destination for that network.
- Create a payment challenge (payee).
charge()builds an x402 payment challenge: a request for payment that carriespayment_requirementsand anonce_binding. The platform fills inpay_tofrom the address registered in step 1. - Sign and pay the challenge (payer).
pay()signs the challenge's authorization locally with the payer's own key and submits it. - The platform verifies and settles. Every signed field is checked against the platform's own records, the org's spend policy is enforced, and the transaction settles on chain.
Keys never leave the caller in either direction: the payee's key signs only the payout-address ownership message, and the payer's key signs only the payment authorization. Neither key is ever sent to Primitive.
Networks and amounts#
x402 runs on two networks: base-sepolia (testnet, the default in every SDK's examples) and base (mainnet). USDC has 6 decimals, so amounts can be given either way:
- Human USDC:
amountUsdc: "0.01"(Node),amount_usdc="0.01"(Python),AmountUsdc: "0.01"(Go). This is the documented easy path and converts to base units for you. - Base units:
amount: "10000"(Node),amount="10000"(Python),Amount: "10000"(Go). Use this only if you've already computed the base-unit value;0.01USDC is"10000".
Provide exactly one of the two on every charge() / create_email_challenge() call. Passing both, or neither, is rejected with an X402Error before any network call.
Registering a payout address#
Register a payout address once per payee org, before the first charge(). The signer proves control of its own address with a local ownership signature that binds the org id, so a captured signature can never register the address under a different org. The org id is resolved automatically from your API key; pass org only to override it.
import { createX402Client } from "@primitivedotdev/sdk/x402";
import { privateKeyToAccount } from "viem/accounts";
const x402 = createX402Client({ apiKey: process.env.PRIMITIVE_API_KEY! });
const payee = privateKeyToAccount(process.env.PAYEE_KEY as `0x${string}`);
await x402.registerPayoutAddress(
{ network: "base-sepolia", label: "treasury" },
{ signer: payee },
);
import os
import primitive
x402 = primitive.create_x402_client(api_key=os.environ["PRIMITIVE_API_KEY"])
payee = primitive.PrivateKeySigner(os.environ["PAYEE_KEY"])
x402.register_payout_address(
signer=payee,
network="base-sepolia",
label="treasury",
)
client := primitive.NewX402Client(primitive.X402ClientOptions{
APIKey: os.Getenv("PRIMITIVE_API_KEY"),
})
payee, err := primitive.NewPrivateKeySigner(os.Getenv("PAYEE_KEY"))
if err != nil {
log.Fatal(err)
}
label := "treasury"
_, err = client.RegisterPayoutAddress(ctx, primitive.X402PayoutRegistrationInput{
Network: "base-sepolia",
Label: &label,
}, payee)
export PRIMITIVE_X402_PRIVATE_KEY=0x...
primitive payments register-payout-address --network base-sepolia --label treasury
charge() resolves pay_to from this directory, so a charge() call before registration has nothing to pay into.
Creating a challenge (payee)#
const challenge = await x402.charge({
amountUsdc: "0.01", // human USDC amount
network: "base-sepolia",
payerOrg: process.env.PAYER_ORG_ID, // org allowed to pay this challenge
description: "API call",
});
challenge = x402.charge(
amount_usdc="0.01", # human USDC amount
network="base-sepolia",
payer_org=os.environ.get("PAYER_ORG_ID"), # org allowed to pay
description="API call",
)
challenge, err := client.Charge(ctx, primitive.X402ChargeInput{
AmountUsdc: "0.01", // human USDC amount
Network: "base-sepolia",
PayerOrg: os.Getenv("PAYER_ORG_ID"), // org allowed to pay
Description: "API call",
})
primitive payments charge --network base-sepolia --amount-usdc 0.01 > challenge.json
Hand the returned challenge to the payer over any out-of-band channel: an API response, a dashboard, or an email. Re-hydrate a challenge later by id with getChallenge / get_challenge / GetChallenge, which is how you retry pay() after a restart. For a challenge that rides a real email thread, see Email-Native x402 Payments.
Paying a challenge (payer)#
The payer signs the interaction-bound authorization locally and submits it; the key never leaves the caller.
import { privateKeyToAccount } from "viem/accounts";
const payer = privateKeyToAccount(process.env.PAYER_KEY as `0x${string}`);
const receipt = await x402.pay(challenge, { signer: payer });
console.log(receipt.status, receipt.settle_tx); // settled, on-chain tx hash
payer = primitive.PrivateKeySigner(os.environ["PAYER_KEY"])
receipt = x402.pay(challenge, signer=payer)
print(receipt.status, receipt.settle_tx) # settled, on-chain tx hash
payer, err := primitive.NewPrivateKeySigner(os.Getenv("PAYER_KEY"))
if err != nil {
log.Fatal(err)
}
receipt, err := client.Pay(ctx, challenge, payer)
if err != nil {
log.Fatal(err)
}
log.Println(receipt.Status, receipt.SettleTx)
export PRIMITIVE_X402_PRIVATE_KEY=0x...
primitive payments pay --challenge-file challenge.json
A viem LocalAccount (Node) and a PrivateKeySigner (Python/Go) both satisfy the signer interface. Any other key source (hardware wallet, remote KMS) works too, as long as it can sign EIP-712 typed data for pay() and a UTF-8 message via personal_sign for payout-address registration.
pay() derives the nonce, builds the authorization, and signs it in one call. Drop to the low-level signing primitives (deriveEip3009Nonce, computePaymentValidityWindow, signInteractionPayment, buildExactEvmPaymentPayload) only when pay() doesn't fit your flow, for example when driving a hardware wallet or a custom submission path. See Low-Level x402 Signing Primitives.
Email-native payments#
Instead of exchanging a challenge id out-of-band, the challenge can ride a real email thread. The payee issues the challenge as an email; the payer signs it into an interaction.json payment step and sends it back as an attachment on the reply. The platform reads the envelope, re-derives the interaction-bound nonce, and settles.
- Node:
createEmailChallenge,parseEmailChallengeFromPart,payEmailChallenge - Python:
create_email_challenge,extract_email_challenge,pay_email_challenge - Go:
CreateEmailChallenge,ExtractEmailChallenge,PayEmailChallenge - CLI:
primitive payments create-email-challengeandprimitive payments pay-email
Full walkthrough: Email-Native x402 Payments.
Spend policy#
The org's spend policy is the org-level guardrail on outbound x402 payments, enforced by the platform before it settles a signed payment:
| Field | Meaning |
|---|---|
paused | Kill-switch. When true, every outbound payment is refused. |
max_per_payment | Cap per payment, in token base units, or no cap. |
max_per_day | Cap per day, in token base units, or no cap. |
allowlist | Allowed payee org ids. null allows any on-net payee; [] denies all. |
Reads return the full policy. Writes merge: only the fields you pass change, omitted fields keep their current value, and passing null (or ClearMaxPerPayment / ClearMaxPerDay in Go) clears a cap.
await x402.setSpendPolicy({ paused: false, max_per_payment: "5000000" });
const policy = await x402.getSpendPolicy();
x402.set_spend_policy({"paused": False, "max_per_payment": "5000000"})
policy = x402.get_spend_policy()
var update primitive.X402SpendPolicyUpdate
update.SetPaused(false).SetMaxPerPayment("5000000")
policy, err := client.SetSpendPolicy(ctx, update)
primitive payments get-spend-policy
primitive payments update-spend-policy --max-per-payment 5000000
Errors#
Every x402 method surfaces a typed error on a client-side, transport, or non-2xx server error: Node and Python throw/raise X402Error, and Go returns a *primitive.X402Error you inspect with errors.As. It carries the HTTP status (0 for a request that never reached the server), the parsed error envelope when present, and the Retry-After header value when the server sent one.
On pay() / Pay(), a status-0 error means the request may never have been sent, so the payment outcome is indeterminate. Don't assume the payment failed. Re-read the challenge with getChallenge / get_challenge / GetChallenge before retrying, or you risk paying twice.
Next steps#
Issue and pay a challenge over a real email thread instead of an out-of-band channel.
Low-Level x402 Signing PrimitivesDrive nonce derivation and EIP-712 signing yourself when pay() doesn't fit your flow.
Spend Policy and Payout Address ManagementRead and update spend caps, allowlists, and the pause switch, and list payout addresses.
x402 Payments from the CLIRegister payouts, charge, and pay challenges from the terminal with primitive payments.
Was this page helpful?