Documentation Index: Fetch llms.txt first to discover every published page. This page is also available as Markdown at /x402-payments-overview-04a296ff.md.
Verified · 8/11/2026

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#

  1. 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.
  2. Create a payment challenge (payee). charge() builds an x402 payment challenge: a request for payment that carries payment_requirements and a nonce_binding. The platform fills in pay_to from the address registered in step 1.
  3. Sign and pay the challenge (payer). pay() signs the challenge's authorization locally with the payer's own key and submits it.
  4. 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.01 USDC 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 },
);

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",
});

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

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.

Tip

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-challenge and primitive 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:

FieldMeaning
pausedKill-switch. When true, every outbound payment is refused.
max_per_paymentCap per payment, in token base units, or no cap.
max_per_dayCap per day, in token base units, or no cap.
allowlistAllowed 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();

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.

Warning

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#

Was this page helpful?

© Primitive SDKs

Powered by Browzer