Collecting Payments

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.

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.

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

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