Collecting Payments

This guide covers the synthetic x402 flow, where the payer is an organization holding an API key and the challenge is passed however you like. For the agent-to-agent variant that carries the challenge, payment, and receipt over a real DKIM-authenticated email thread, see x402 over Email. If you instead received a request and want to pay it, see Pay an x402 Request over Email.

Primitive supports x402 payments: stablecoin (USDC) payments between agents and organizations, settled directly on-chain. Settlement is non-custodial. Funds move from payer to payee through an EIP-3009 authorization the payer signs with their own key, and Primitive never holds the money or the keys.

The flow has two sides:

  • The payee registers a payout address, then creates a challenge (a request for a specific amount).
  • The payer receives the challenge, signs the bound authorization locally, and submits it. Primitive verifies every signed field against its own record of the challenge, applies the payer's spend policy, and settles on-chain through a facilitator.

Amounts are always in token base units. USDC has 6 decimals, so "10000" is 0.01 USDC. Supported networks are base (mainnet) and base-sepolia (testnet).

Prerequisites

  • An API key. See Quickstart.
  • A wallet you control on the chosen network. The private key signs locally and is never sent to Primitive. The SDKs accept a private key directly; the CLI reads it from the PRIMITIVE_X402_PRIVATE_KEY environment variable.
  • To receive payments, a registered payout address (below). To pay, USDC in the signing wallet.

You do not need to look up your organization id: the CLI and SDKs resolve it for you.

Requesting access

x402 is in an invite-only soft launch. The capability is gated by the x402_payments organization entitlement (and interactions_enabled for the email-native flow), granted by the Primitive team and off by default. If the x402 endpoints return feature_disabled, the feature is not enabled for your organization yet. To request access, contact support or email the Primitive dev_help agent at dev_help@agent.primitive.dev.

Setting up a wallet

If you do not already have a wallet, create one and fund it. For experimenting, use the base-sepolia testnet so no real funds are at risk:

# Create a fresh key with any standard tool, for example:
cast wallet new        # from Foundry; prints an address and private key
export PRIMITIVE_X402_PRIVATE_KEY=0x<the-private-key>

To pay on base-sepolia, fund the wallet with test ETH (a Base Sepolia faucet) and test USDC (Circle's testnet faucet). To receive, you only need the address; no balance is required. For base mainnet, use a real funded wallet. Keep mainnet keys in a secret manager, never in shell history.

Step 1: Register a payout address (payee)

A challenge's pay_to is resolved server-side from your registered default payout address, never from client input. Register once per network before requesting payments. You prove control of the address by signing an org-bound message locally; the recovered address becomes your default for that network.

export PRIMITIVE_X402_PRIVATE_KEY=0x<your-wallet-private-key>


primitive payments register-payout-address --network base-sepolia

The CLI resolves your organization id automatically and prints the registered address.

import { createX402Client } from '@primitivedotdev/sdk/x402';
import { privateKeyToAccount } from 'viem/accounts';


const x402 = createX402Client({ apiKey: process.env.PRIMITIVE_API_KEY! });
const signer = privateKeyToAccount(process.env.WALLET_PRIVATE_KEY as `0x${string}`);


// Your org id is resolved from your account automatically. Pass { org } to override.
const address = await x402.registerPayoutAddress(
  { network: 'base-sepolia' },
  { signer },
);
console.log(address.address, address.is_default);

Step 2: Request a payment (payee)

Create a challenge for the amount you want to collect. The response carries everything the payer needs to sign, including the nonce_binding and payment_requirements. Hand the whole challenge object to the payer, for example in an email reply.

# Amount as human USDC; the challenge JSON prints to stdout.
primitive payments charge --amount-usdc 0.01 --network base-sepolia


# Save it to hand to the payer:
primitive payments charge --amount-usdc 0.01 > challenge.json

payments create-challenge is the equivalent low-level command if you prefer to pass base units with --amount 10000.

const challenge = await x402.charge({
  amountUsdc: '0.01', // or amount: '10000' in base units
  network: 'base-sepolia',
  description: 'Invoice #1024',
});
// Send `challenge` to the payer (e.g. as JSON in an email reply).

Challenge creation is idempotent: pass an Idempotency-Key header (or idempotencyKey in the Node SDK's charge()) and a retried create with the same key returns the original challenge instead of minting a duplicate.

Step 3: Pay a challenge (payer)

The payer signs the interaction-bound EIP-3009 authorization locally and submits it. The signing key never leaves the payer's machine. Paying an already-settled challenge returns the original receipt, so a retry is safe.

export PRIMITIVE_X402_PRIVATE_KEY=0x<payer-wallet-private-key>


# The challenge JSON is whatever the payee gave you.
primitive payments pay --challenge-file challenge.json

The challenge can also be piped on stdin or passed inline with --challenge '<json>'.

import { createX402Client, type X402Challenge } from '@primitivedotdev/sdk/x402';
import { privateKeyToAccount } from 'viem/accounts';


const x402 = createX402Client({ apiKey: process.env.PRIMITIVE_API_KEY! });
const signer = privateKeyToAccount(process.env.WALLET_PRIVATE_KEY as `0x${string}`);


const challenge: X402Challenge = JSON.parse(receivedChallengeJson);
const receipt = await x402.pay(challenge, { signer });
console.log(receipt.status, receipt.settle_tx);

Spend policy (payer safety rail)

Every payer has a spend policy that gates outbound payments before anything is signed or settled. It has a kill-switch, a per-payment cap, a per-day cap, and an optional payee allowlist. Caps are in token base units. A payment is checked against the signed amount, not the amount the challenge advertises.

# Read the current policy
primitive payments get-spend-policy


# Pause all outbound payments (kill-switch)
primitive payments update-spend-policy --paused true


# Cap any single payment at 0.05 USDC (run --help for every field)
primitive payments update-spend-policy --max-per-payment 50000

The policy is a merge update: fields you omit keep their current value, and an explicit null clears a cap. A partial update can never silently re-enable a paused policy.

Collecting payments from a Function

A common pattern is an inbound Function that turns an email into a payment request. The handler creates a challenge and replies in-thread with it, so the sender (or their agent) can pay.

import { createX402Client } from '@primitivedotdev/sdk/x402';


export default {
  async fetch(request: Request, env: Record<string, string>) {
    // Verify the Primitive signature and parse the inbound event first
    // (see the Functions guide), then:
    const x402 = createX402Client({ apiKey: env.PRIMITIVE_API_KEY });
    const challenge = await x402.charge({
      amount: '10000',
      network: 'base-sepolia',
      description: 'Access fee',
    });


    // Reply in-thread with the challenge JSON so the payer can settle it.
    // ... use the inbound SDK client to reply ...
    return new Response(JSON.stringify({ ok: true, challenge_id: challenge.id }));
  },
};

Knowing when you have been paid

When a challenge you created is settled, Primitive delivers a payment.settled webhook event to your subscribed endpoints (and a payment.failed event if settlement fails). This is the real-time way for a payee to learn of payment. The event payload is:

{
  "type": "payment.settled",
  "challenge_id": "11111111-1111-4111-8111-111111111111",
  "network": "base-sepolia",
  "amount": "10000",
  "asset": "0x036CbD53842c5426634e7929541eC2318f3dCF7e",
  "payer_org": "22222222-2222-4222-8222-222222222222",
  "settle_tx": "0x..."
}

payment.failed carries failure_reason instead of settle_tx. Any enabled webhook endpoint receives these unless it filters event types, in which case add payment.settled / payment.failed to its subscription.

You can also read settlement state directly. Poll the challenge until its status is settled and a settle_tx is present:

primitive payments get-challenge --id <challenge-id>

In code, call getChallenge(id) (Node), get_challenge(id) (Python), or GetChallenge(ctx, id) (Go) and check status. A settled status carries the on-chain settle_tx; failed carries a failure_reason. The payer's pay call also returns the receipt synchronously, so a payee and payer in the same process (or a Function that both charges and settles) get the result immediately without polling.

Troubleshooting

Common errors and how to fix them:

  • no_payout_address: the payee has not registered a payout address for this network. Run primitive payments register-payout-address --network <network>.
  • payment_declined: the payer's spend policy refused the payment. Check it with primitive payments get-spend-policy. If it is paused, re-enable with primitive payments update-spend-policy --paused false; if a cap was hit, raise it.
  • settlement_failed: the on-chain settlement did not complete. The most common cause is insufficient USDC in the paying wallet on that network. Fund the wallet and retry; paying again is safe.
  • challenge_expired: the challenge passed its expiry before being paid. The payee creates a new one. Use --expires-in (or expiresIn) for longer-lived challenges.
  • payment_verification_failed: the signed payment did not match the challenge. Confirm you are paying with the wallet and network the challenge was issued for.

Testnet vs mainnet

Use base-sepolia (testnet) while developing so no real funds are at risk: fund the paying wallet from a Base Sepolia ETH faucet plus Circle's testnet USDC faucet. Switch to base (mainnet) only with a real funded wallet, and keep mainnet keys in a secret manager, never in shell history. The network is chosen per challenge; a challenge issued on one network can only be paid on that same network.

Refunds

x402 settlement is final and non-custodial: funds move directly from the payer's wallet to the payee's registered payout address on-chain, and Primitive never holds them. There is no clawback, reverse, or void operation. A refund is therefore a new payment in the opposite direction: the original payee pays the original payer for the refunded amount. Treat it as a fresh forward payment, not an undo of the original.

Settle limits (email-native)

The synthetic flow gates outbound payments with the payer's spend policy (above). The email-native flow additionally enforces per-payee-org settle caps when the payment settles (a 25 USDC/day and 250 USDC/month cap per payee org, plus a fixed 10 USDC per-settle ceiling). See the Settle limits section in x402 over Email for the full breakdown and the reject reasons.

Reconciliation

GET /v1/x402/payments is the payment ledger: a cursor-paginated, newest-first list of every payment this org is party to, both received (challenges you issued as payee) and sent (challenges you paid). Each row carries the challenge_id, direction, status, amount, asset, network, counterparty, and settle_tx, so you can enumerate settled payments without already holding each challenge id. Filter with status, direction, network, and the inclusive created_after/created_before date bounds; page with limit (default 50, max 200) and the returned cursor — treat cursor === null as the end of the ledger, not a short page. Request the same URL with an Accept: text/csv header to download the full filtered set as a CSV attachment for month-end reconciliation; if the export hits its size ceiling the response sets X-Truncated: true, so narrow the date range and re-pull rather than assuming you have every row.

Endpoint reference

OperationMethod and path
Register payout addressPOST /v1/x402/payout-addresses
List payout addressesGET /v1/x402/payout-addresses
Create challengePOST /v1/x402/challenges
Get challengeGET /v1/x402/challenges/{id}
Pay challengePOST /v1/x402/challenges/{id}/pay
Get spend policyGET /v1/x402/spend-policy
Update spend policyPUT /v1/x402/spend-policy
List declined paymentsGET /v1/x402/declined-payments
List payments (ledger)GET /v1/x402/payments

See the API reference for the full request and response schemas, and the SDKs and CLI pages for installation.