Email-Native Payments (Python SDK)
Issue an x402 payment challenge over a real email thread and pay it with a signed interaction.json attachment, using create_email_challenge, extract_email_challenge, and pay_email_challenge.
Use email-native x402 payments when the payment challenge should ride a real email thread instead of an out-of-band challenge id. The payee issues the challenge as an email; the payer signs it locally into an interaction.json payment step and replies with that file attached.
This is the Python SDK's version of the shared email-native x402 payment flow. For the synthetic-challenge alternative (create a challenge, hand the id to the payer over any channel), see Creating and Paying Challenges.
Every method on X402Client raises primitive.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 or failed local validation before any network call. See the Python SDK Error Reference for the full catalog.
Prerequisites#
You need two wallet keys, a Primitive API key, a registered payout address, and a way to send and reply to mail.
- A payee wallet key in
PAYEE_KEYand a payer wallet key inPAYER_KEY(0x-prefixed hex private keys). Keys never leave the process that holds them. - A Primitive API key in
PRIMITIVE_API_KEY, set on the payee'sX402Client. - The payee's payout address already registered for the network you're using, see Registering Payout Addresses and Spend Policy.
- A way for the payee to send outbound mail and the payer to receive and reply to it (the SDK's
client.send/client.reply, covered in Sending Email and Replying and Forwarding).
The flow#
The payee issues a challenge email, the payer extracts and signs it locally, and the payer replies with the signed interaction.json attached so the platform can settle.
- 1
Issue the challenge as an email (payee)#
Call
create_email_challengewith the payee's sending address (from_), the payer's address (to), and the amount. Thepay_topayout wallet and token asset are resolved server-side from the payee's registered payout address, you only supply the addresses, amount, and network.import os import primitive x402 = primitive.create_x402_client(api_key=os.environ["PRIMITIVE_API_KEY"]) issued = x402.create_email_challenge( from_="payee@your-domain.example", # your sending address (funds receiver) to="payer@their-domain.example", # the payer's address amount_usdc="0.01", network="base-sepolia", ) # issued.interaction_id is the email thread the payment is bound to; # issued.challenge carries the payment_requirements + nonce_binding to sign.create_email_challengesends the challenge email itself; you don't callclient.sendseparately. Provide exactly one ofamount_usdc(human USDC, e.g."0.01") oramount(base units, e.g."10000"); passing both raisesX402Error.TipPass
idempotency_keytocreate_email_challengeto make retries safe: retrying with the same key returns the original challenge instead of sending a second email. - 2
Extract the challenge from the inbound email (payer)#
The payer receives the challenge as an
interaction.jsonMIME part on an inbound email. Pass the part's bytes toextract_email_challengerather than hand-parsing the envelope: it validates the wire shape and rebuilds the nonce binding for you.from primitive import extract_email_challenge # `interaction_part` is the body of the inbound email's `interaction.json` # attachment (str, bytes, or an already-parsed dict). issued = extract_email_challenge(interaction_part)extract_email_challengeraisesprimitive.X402Error(status0) on any malformed or non-challenge part: bad JSON, wrong protocol, wrong step, or a payment-requirements shape that fails validation. The resulting object'schallenge_idis always empty because the platform's private challenge id is never carried on the wire;pay_email_challengedoesn't need it, since it binds tointeraction_idand the challenge step id instead. - 3
Sign the payment locally (payer)#
Build the signed payment step with
pay_email_challenge. This does not send anything: it returns the signed envelope and its canonical JSON bytes, ready to attach to a reply.import base64 import os import primitive x402 = primitive.create_x402_client(api_key=os.environ["PRIMITIVE_API_KEY"]) payer = primitive.PrivateKeySigner(os.environ["PAYER_KEY"]) built = x402.pay_email_challenge(issued, signer=payer) # `built.json` is the interaction.json body. attachment: primitive.SendAttachment = { "filename": "interaction.json", "content_type": "application/json", "content_base64": base64.b64encode(built.json.encode("utf-8")).decode(), }The validity window (
valid_after/valid_before) is computed and clamped into the platform's accepted band automatically, so you never hand-setvalid_before. The band keeps at least 60 seconds of settlement headroom and caps the total window at 24 hours; see Low-Level Payment Signing for how that window is derived. - 4
Reply with the signed envelope attached (payer)#
Attach the built
interaction.jsonto a reply on the same thread using the client'sreplymethod. The platform reads the envelope, re-derives the interaction-bound nonce, and settles on chain.import os import primitive client = primitive.client(api_key=os.environ["PRIMITIVE_API_KEY"]) client.reply( challenge_email, {"text": "Payment attached.", "attachments": [attachment]}, )challenge_emailis theReceivedEmailobject from normalizing the inbound challenge; see Receiving and Parsing Inbound Email.
Verifying settlement#
Confirm settlement from webhook events, not from the return value of pay_email_challenge: settlement happens asynchronously after the platform reads the reply. Listen for the payment.settled / payment.failed events, or the interaction.x402.* lifecycle events, as described in Handling Webhook Events.
pay_email_challenge only signs; it does not confirm delivery. Treat a successful call as "the payment step is ready to send," not "the payment settled." Confirm settlement from the webhook event, not from the return value of pay_email_challenge.
Common failure modes#
Most failures come from hand-building a challenge object or attaching the signed envelope with the wrong filename or content type.
| Symptom | Cause | Fix |
|---|---|---|
email challenge is missing or malformed: interaction_id (mismatch with challenge.nonce_binding.interaction_id) | The envelope's interaction_id disagrees with the nested nonce_binding.interaction_id | Re-extract with extract_email_challenge; don't hand-construct the challenge object |
email challenge is missing or malformed: challenge.expires_at | Challenge object built manually and missing required fields | Always build challenges via create_email_challenge / extract_email_challenge, never by hand |
| Payment step signed but never settles | Reply sent without the interaction.json attachment, or attached under the wrong filename/content type | Use exactly filename="interaction.json", content_type="application/json" |
Next steps#
The synthetic-challenge flow: create a challenge with charge() and settle it with pay() over any out-of-band channel.
Low-Level Payment SigningDrive nonce derivation, validity windows, and EIP-712 signing directly when pay_email_challenge doesn't fit your flow.
Handling Webhook EventsParse payment.settled, payment.failed, and interaction.x402.* events to confirm a payment's outcome.
Registering Payout Addresses and Spend PolicyRegister the payee's payout address and configure spend caps before issuing challenges.
Was this page helpful?