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

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.

Note

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_KEY and a payer wallet key in PAYER_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's X402Client.
  • 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. 1

    Issue the challenge as an email (payee)#

    Call create_email_challenge with the payee's sending address (from_), the payer's address (to), and the amount. The pay_to payout 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_challenge sends the challenge email itself; you don't call client.send separately. Provide exactly one of amount_usdc (human USDC, e.g. "0.01") or amount (base units, e.g. "10000"); passing both raises X402Error.

    Tip

    Pass idempotency_key to create_email_challenge to make retries safe: retrying with the same key returns the original challenge instead of sending a second email.

  2. 2

    Extract the challenge from the inbound email (payer)#

    The payer receives the challenge as an interaction.json MIME part on an inbound email. Pass the part's bytes to extract_email_challenge rather 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_challenge raises primitive.X402Error (status 0) on any malformed or non-challenge part: bad JSON, wrong protocol, wrong step, or a payment-requirements shape that fails validation. The resulting object's challenge_id is always empty because the platform's private challenge id is never carried on the wire; pay_email_challenge doesn't need it, since it binds to interaction_id and the challenge step id instead.

  3. 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-set valid_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. 4

    Reply with the signed envelope attached (payer)#

    Attach the built interaction.json to a reply on the same thread using the client's reply method. 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_email is the ReceivedEmail object 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.

Warning

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.

SymptomCauseFix
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_idRe-extract with extract_email_challenge; don't hand-construct the challenge object
email challenge is missing or malformed: challenge.expires_atChallenge object built manually and missing required fieldsAlways build challenges via create_email_challenge / extract_email_challenge, never by hand
Payment step signed but never settlesReply sent without the interaction.json attachment, or attached under the wrong filename/content typeUse exactly filename="interaction.json", content_type="application/json"

Next steps#

Was this page helpful?

© Primitive SDKs

Powered by Browzer