Email-Native x402 Payments
Issue and pay x402 payment challenges that ride a real email thread using createEmailChallenge, parseEmailChallengeFromPart, and payEmailChallenge, for when the payment needs to travel over email instead of an out-of-band challenge id.
Use email-native x402 payments when the payment challenge needs to travel inside a real email thread instead of being exchanged out-of-band (API call, dashboard link). The payee issues the challenge as an email; the payer signs it locally and replies with the signed payment attached as interaction.json. Primitive reads that attachment, re-derives the interaction-bound nonce, and settles on chain.
For the synthetic-challenge flow (charge() / pay() with an out-of-band challenge id), see Charging and Registering Payout Addresses and Paying a Challenge. The overall non-custodial payment model (payout registration, spend policy, networks, amount units) is explained on x402 Payments Overview.
The payer's signing key never leaves their machine. payEmailChallenge signs locally and returns bytes to attach; it does not submit anything over the network itself.
What you need before starting#
- A payee sending address you control (verified outbound domain), used as the
fromof the challenge email. - The payer's email address, to send the challenge
to. - The payer's wallet private key (
PAYER_KEY), held only on the payer's side. @primitivedotdev/sdkinstalled andPRIMITIVE_API_KEYset. See the Node.js SDK Quickstart.
Issue the challenge as an email (payee)#
- 1
Construct the x402 client#
import { createX402Client } from "@primitivedotdev/sdk/x402"; const x402 = createX402Client({ apiKey: process.env.PRIMITIVE_API_KEY! }); - 2
Call createEmailChallenge#
The
pay_topayout wallet and the token asset are resolved server-side from your registered payout address; you only supply the addresses, amount, and network. Pass exactly one ofamountUsdc(human USDC, the recommended path) oramount(token base units):const issued = await x402.createEmailChallenge({ from: "payee@your-domain.example", // your sending address (the funds receiver) to: "payer@their-domain.example", // the payer's address amountUsdc: "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 the payer signs.This sends the challenge email from
fromtotoand returns theX402EmailChallenge, including the realinteraction_id(the thread id,uuid@domain) the payment binds to.X402EmailChargeInputalso acceptsdescription,resource,expiresIn(seconds, default 300 seconds / 5 minutes), andidempotencyKey; retrying with the same key returns the original challenge without sending a second email.
createEmailChallenge takes exactly one of amount or amountUsdc. Passing both, or neither, throws an X402Error before any network call.
Parse the challenge on the payer's side#
The payer receives the challenge as an interaction.json MIME part on an inbound email (filename interaction.json, content type application/json). Don't hand-parse it: parseEmailChallengeFromPart validates the envelope shape (protocol, step, nonce binding, payment requirements) and returns the typed X402EmailChallenge.
import { parseEmailChallengeFromPart } from "@primitivedotdev/sdk/x402";
// `interactionPart` is the body of the inbound email's `interaction.json`
// attachment: a string, a Buffer/Uint8Array, or an already-parsed object.
const issued = parseEmailChallengeFromPart(interactionPart);
parseEmailChallengeFromPart throws an X402Error with status 0 on any malformed or non-challenge part: a wrong interaction_version, a wrong protocol, a step other than "challenge", a malformed challenge_nonce, or missing payment_requirements fields. Treat that error as "this attachment isn't a valid x402 challenge," not a network failure.
Sign and reply with the payment (payer)#
- 1
Sign the challenge with payEmailChallenge#
payEmailChallengederives the interaction-bound authorization, signs it locally with your signer, and returns the signed payment-step envelope plus its canonical JSON bytes. It does not send anything over the network; you attach the result yourself.import { privateKeyToAccount } from "viem/accounts"; const payer = privateKeyToAccount(process.env.PAYER_KEY as `0x${string}`); const built = await x402.payEmailChallenge(issued, { signer: payer }); // `built.json` is the interaction.json body to attach to the reply.The validity window (
validAfter/validBefore) is computed and clamped into the platform's accepted band automatically, so you never hand-setvalidBefore. See Low-Level x402 Signing Primitives if you need to control that window directly. - 2
Reply to the challenge email with the signed payment attached#
Attach
built.jsonas aninteraction.jsonfile on a reply to the challenge email, using the normal reply flow:import { Buffer } from "node:buffer"; import primitive from "@primitivedotdev/sdk"; const mail = primitive.client({ apiKey: process.env.PRIMITIVE_API_KEY! }); await mail.reply(challengeEmail, { text: "Payment attached.", attachments: [ { filename: "interaction.json", content_type: "application/json", content_base64: Buffer.from(built.json, "utf8").toString("base64"), }, ], });challengeEmailis theReceivedEmailnormalized from the inbound challenge webhook. Primitive reads the envelope from the attachment, re-derives the interaction-bound nonce, and settles on chain.
Expected result: the reply send succeeds and Primitive settles the payment on chain. Confirm the outcome asynchronously via the payment.settled / payment.failed webhook or the interaction.x402.* events; see Handling Payment and Interaction Webhook Events.
The signed authorization stays settleable only inside its validity window, which is clamped to at most 24 hours with at least 60 seconds of settlement headroom. Attach and send built.json promptly; an envelope whose window has lapsed is rejected, and you must issue a new challenge.
How this differs from the Go SDK#
The Go SDK exposes the identical flow with different function names: client.CreateEmailChallenge, primitive.ExtractEmailChallenge, and client.PayEmailChallenge, using a *primitive.PrivateKeySigner in place of a viem LocalAccount.
// main.go
import (
"encoding/base64"
primitive "github.com/primitivedotdev/sdks/sdk-go"
)
issued, err := client.CreateEmailChallenge(ctx, primitive.X402EmailChargeInput{
From: "payee@your-domain.example",
To: "payer@their-domain.example",
AmountUsdc: "0.01",
Network: "base-sepolia",
})
// On the payer side, after extracting the challenge from the inbound
// interaction.json part:
built, err := client.PayEmailChallenge(issued, payer)
_, err = client.Reply(ctx, challengeEmail, primitive.ReplyParams{
BodyText: "Payment attached.",
Attachments: []primitive.SendAttachment{
{Filename: "interaction.json", ContentBase64: base64.StdEncoding.EncodeToString([]byte(built.JSON))},
},
})
The Python SDK mirrors this with create_email_challenge, extract_email_challenge, and pay_email_challenge; see Email-Native Payments (Python).
Next steps#
Register a payout address and create synthetic challenges as the payee.
Paying a ChallengeSign and settle a synthetic (non-email) challenge with pay().
Low-Level x402 Signing PrimitivesDrive nonce derivation and validity-window computation yourself when payEmailChallenge doesn't fit your flow.
Handling Payment and Interaction Webhook EventsBranch on payment.settled, payment.failed, and interaction.x402.* deliveries.
Was this page helpful?