Creating and Paying Challenges
Create an x402 payment challenge as the payee with X402Client.charge, then sign and settle it as the payer with X402Client.pay, using non-custodial USDC payments on Base.
Use X402Client.charge to request a USDC payment as the payee, and X402Client.pay to sign and settle it as the payer. Reach for this out-of-band flow whenever the challenge id travels through your own channel, an API response, a dashboard, a database row, rather than riding a real email thread; for the email-carried variant, see Email-Native Payments.
This page covers the synthetic-challenge flow end to end: creating the x402 payment challenge, handing it to a payer, and settling it. For the non-custodial payment model itself (registering a payout address, spend policy, how settlement works), see x402 Payments Overview.
Both charge() and pay() require a payout address already registered for the payee's org. See Registering Payout Addresses and Spend Policy if you haven't done that yet.
Prerequisites#
primitivedotdevinstalled (pip install primitivedotdev)- A Primitive API key (
prim_testin examples below, orPRIMITIVE_API_KEYin the environment) - The payee's payout address already registered for the target network
- A payer wallet private key, held only by the payer (
PAYER_KEYin examples below)
Create the client#
Build an X402Client with primitive.create_x402_client, the factory that returns the client for every x402 operation.
import os
import primitive
x402 = primitive.create_x402_client(api_key=os.environ["PRIMITIVE_API_KEY"])
create_x402_client and X402Client are both exported from primitive and primitive.x402. With no api_key argument it reads PRIMITIVE_API_KEY from the environment.
- 1
Create the challenge (payee side)#
Call
charge()with an amount and a network. Provide exactly one ofamount_usdc(a human USDC decimal string) oramount(token base units), passing both raisesX402Error.challenge = x402.charge( amount_usdc="0.01", # human USDC amount network="base-sepolia", payer_org=os.environ.get("PAYER_ORG_ID"), # org allowed to pay description="API call", ) print(challenge.id, challenge.expires_at)amount_usdc="0.01"converts to base units"10000"internally (USDC has 6 decimals). Useamount="10000"directly if you already have a base-unit value.networkdefaults tobase-sepolia(testnet); usebasefor mainnet.payer_orgis optional and binds the challenge to a specific paying org on-net. Other optional fields:description,resource(a URL identifying what's being paid for),expires_in(seconds until the challenge expires, defaulting to 3600 seconds / 1 hour), andidempotency_key(retryingcharge()with the same key returns the original challenge instead of creating a duplicate).The returned
challengecarriespayment_requirementsandnonce_binding, the fields the payer signs over. Hand the whole object to the payer over any out-of-band channel (an API response, a message, a database row). - 2
Sign and settle the challenge (payer side)#
The payer builds a
PrivateKeySignerfrom their own key and callspay(). The key never leaves the process;pay()signs an EIP-3009transferWithAuthorizationlocally and submits it.payer = primitive.PrivateKeySigner(os.environ["PAYER_KEY"]) receipt = x402.pay(challenge, signer=payer) print(receipt.status, receipt.settle_tx) # settled, 0x-prefixed tx hashreceipt.statusis"settled"on success;receipt.settle_txis the on-chain settlement transaction hash.
What each call returns#
charge() returns an X402Challenge dataclass with id, network, amount (base units), pay_to, nonce_binding, payment_requirements, and expires_at. pay() returns an X402Receipt with id, status, and settle_tx:
print(challenge.id, challenge.amount, challenge.pay_to)
# 11111111-1111-4111-8111-111111111111 10000 0x1111111111111111111111111111111111111111
print(receipt.status, receipt.settle_tx)
# settled 0xaaaa...
settle_tx is None when the platform has not yet recorded a settlement transaction.
Re-hydrating a challenge#
Call get_challenge(id) to fetch a challenge you already created, for example to retry pay() after a restart:
import os
import primitive
x402 = primitive.create_x402_client(api_key=os.environ["PRIMITIVE_API_KEY"])
challenge = x402.get_challenge("11111111-1111-4111-8111-111111111111")
Validation and failure modes#
Both methods validate their inputs locally and raise X402Error with status 0 before any network call. charge() checks the amount:
- Passing both
amountandamount_usdc, or neither, raisesX402Errorwith status0. - A non-positive, malformed, or over-6-decimal
amount_usdc(e.g."0","abc","1.1234567") raisesX402Errorbefore any network call. - An unknown keyword argument (a typo like
payer_x) raisesX402Errornaming the bad key rather than being silently dropped.
pay() validates the challenge is fully hydrated and unexpired before signing, so a malformed challenge fails with a named X402Error instead of an opaque error mid-sign:
- A missing or malformed
payment_requirements(badmaxAmountRequired,payTo,asset, orextra) raisesX402Error. - A challenge that already expired, or expires within the settlement margin, raises
X402Errorwith"already expired"in the message, and never reaches the server. - Calling
pay()without a signer, or with an object missingsign_typed_data, raisesX402Error.
Every method raises X402Error on a client-side, transport, or non-2xx server error. status is the HTTP status, or 0 for a request that never reached the server. On pay(), a status == 0 error means the request may not have been sent, the payment outcome is indeterminate. Don't assume the payment failed; check get_challenge() or wait for a payment.* webhook before retrying. See the full error reference and Handling Webhook Events.
amount_usdc is the documented easy path for specifying an amount. Use raw amount in base units only when you've already computed the value yourself.
Next steps#
Issue and pay an x402 challenge that rides a real email thread instead of an out-of-band channel.
Registering Payout Addresses and Spend PolicyRegister the payee's payout address and configure spend caps and allowlists before charging.
Low-Level Payment SigningDrive nonce derivation, validity windows, and EIP-712 signing directly when pay() doesn't fit your flow.
Python SDK Error ReferenceLook up every X402Error condition and the suggested fix.
Was this page helpful?