Charging and Registering Payout Addresses
Register your wallet as a payout destination and issue x402 payment challenges with charge(), the payee-side half of Primitive's non-custodial USDC payments.
Use this guide when you're the payee: you want to get paid in USDC and need to (1) tell Primitive where to send funds, and (2) issue a payment request another agent can sign and pay. Both steps use the X402Client from @primitivedotdev/sdk/x402.
For the full non-custodial payment model (how signing, settlement, and spend policy fit together across every SDK), see x402 Payments Overview. This page covers only the payee-side calls: registerPayoutAddress and charge().
If the payment needs to ride a real email thread instead of an out-of-band challenge id, use Email-Native x402 Payments instead. This page covers the synthetic-challenge flow.
Construct the x402 client#
Build the client from the x402 subpath export. It defaults to reading PRIMITIVE_API_KEY from the environment.
import { createX402Client } from "@primitivedotdev/sdk/x402";
const x402 = createX402Client({ apiKey: process.env.PRIMITIVE_API_KEY! });
Register a payout address (one time)#
registerPayoutAddress proves control of a wallet by signing an ownership message with the wallet's own key, then registers that address as your org's default payout destination on a given network. charge() resolves its pay_to field from this registered address, so register before your first charge() call.
- 1
Hold the payee's private key in a viem LocalAccount#
The signing key never leaves your process. A viem
LocalAccountbuilt withprivateKeyToAccountsatisfies the signer interfaceregisterPayoutAddressexpects (it uses the account'ssignMessage).import { privateKeyToAccount } from "viem/accounts"; const payee = privateKeyToAccount(process.env.PAYEE_KEY as `0x${string}`); - 2
Call registerPayoutAddress#
Pass the target
networkand an optionallabel. The ownership message binds your organization id, so a captured signature can never register the address under a different org. You don't passorgyourself; it's resolved automatically from your account (supplyorgonly to override).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 }, ); - 3
Verify the registration#
List your registered payout addresses to confirm the new default landed:
const addresses = await x402.listPayoutAddresses(); console.log(addresses);Each entry carries
address,network,label,is_default, andverified_at; look for the newly registered address onbase-sepoliamarkedis_default: true. Once registered, everycharge()on that network resolvespay_toto this address automatically, so you never pass a payout address tocharge()directly.
Registration is per network. If you also collect on mainnet, run the same registerPayoutAddress call again with network: "base" and a signer for that wallet.
Create a payment challenge with charge()#
charge() creates an x402 payment challenge: the object the payer signs and pays with pay(). It carries payment_requirements and nonce_binding, and Primitive fills in pay_to from your registered payout address.
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",
});
Expected result: challenge is an X402Challenge object with fields including id, network, amount (base units), pay_to, nonce_binding, payment_requirements, and expires_at.
Amount: human USDC vs. base units#
Pass exactly one of:
| Field | Format | Example | Notes |
|---|---|---|---|
amountUsdc | human USDC decimal string | "0.01" | Recommended default. Converted to base units for you. |
amount | token base units | "10000" | USDC has 6 decimals, so "10000" = 0.01 USDC. Use only if you already have a base-unit value. |
Passing both, or neither, throws an X402Error before any network call.
charge() options#
| Option | Required | Description |
|---|---|---|
amountUsdc / amount | one of the two | See table above. |
network | no | "base-sepolia" (testnet, used in examples) or "base" (mainnet). Defaults to "base-sepolia". |
payerOrg | no | The org id allowed to pay this challenge (on-net binding). |
description | no | Human-readable description shown to the payer. |
resource | no | A URL identifying the thing being paid for. |
expiresIn | no | Seconds until the challenge expires. Defaults to 1 hour. |
idempotencyKey | no | Retrying charge() with the same key returns the original challenge instead of creating a duplicate. |
charge() rejects unknown option keys (for example a typo like payer_org instead of payerOrg) with an X402Error at call time rather than silently dropping the field. Check spelling against the table above if you hit unknown charge() option "...".
Hand the challenge to the payer#
Deliver the returned challenge object to the payer over any out-of-band channel: an API response, a dashboard, a queued message. The payer needs the whole object to sign and pay it; see Paying a Challenge.
// Example: return the challenge from your own API route
return Response.json(challenge);
If the payer's process restarts before paying, re-hydrate the challenge by id instead of reissuing it:
const challenge = await x402.getChallenge(challengeId);
Errors#
Every method on X402Client throws X402Error on a client-side, transport, or non-2xx server error. It carries:
status: the HTTP status, or0for a request that never reached the server (validation failures, malformed input, network errors).body: the parsed error envelope when present.retryAfter: theRetry-Afterheader value, when the server sent one.
import { X402Error } from "@primitivedotdev/sdk/x402";
try {
await x402.charge({ amountUsdc: "0.01" });
} catch (err) {
if (err instanceof X402Error) {
console.error(err.status, err.message, err.retryAfter);
}
}
For the full error catalog across payments, webhooks, and email, see Node.js SDK Errors.
Next steps#
Sign and submit payment for the challenge you just created, as the payer.
Email-Native x402 PaymentsIssue the same kind of challenge over a real email thread instead of an out-of-band id.
Spend Policy and Payout Address ManagementGuard outbound payments with caps, an allowlist, and a kill-switch, and list registered payout addresses.
Low-Level x402 Signing PrimitivesDrive nonce derivation and payload assembly yourself when the high-level flow doesn't fit.
Was this page helpful?