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

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().

Note

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. 1

    Hold the payee's private key in a viem LocalAccount#

    The signing key never leaves your process. A viem LocalAccount built with privateKeyToAccount satisfies the signer interface registerPayoutAddress expects (it uses the account's signMessage).

    import { privateKeyToAccount } from "viem/accounts";
    
    const payee = privateKeyToAccount(process.env.PAYEE_KEY as `0x${string}`);
    
  2. 2

    Call registerPayoutAddress#

    Pass the target network and an optional label. The ownership message binds your organization id, so a captured signature can never register the address under a different org. You don't pass org yourself; it's resolved automatically from your account (supply org only 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. 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, and verified_at; look for the newly registered address on base-sepolia marked is_default: true. Once registered, every charge() on that network resolves pay_to to this address automatically, so you never pass a payout address to charge() directly.

Tip

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:

FieldFormatExampleNotes
amountUsdchuman USDC decimal string"0.01"Recommended default. Converted to base units for you.
amounttoken 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#

OptionRequiredDescription
amountUsdc / amountone of the twoSee table above.
networkno"base-sepolia" (testnet, used in examples) or "base" (mainnet). Defaults to "base-sepolia".
payerOrgnoThe org id allowed to pay this challenge (on-net binding).
descriptionnoHuman-readable description shown to the payer.
resourcenoA URL identifying the thing being paid for.
expiresInnoSeconds until the challenge expires. Defaults to 1 hour.
idempotencyKeynoRetrying charge() with the same key returns the original challenge instead of creating a duplicate.
Warning

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, or 0 for a request that never reached the server (validation failures, malformed input, network errors).
  • body: the parsed error envelope when present.
  • retryAfter: the Retry-After header 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#

Was this page helpful?

© Primitive SDKs

Powered by Browzer