Agents
x402 over SEPA
x402 lets a server answer 402 Payment Required and a client pay by signing an authorization that a facilitator verifies and settles. This service plays the payer and the facilitator, with a bank mandate where a blockchain wallet would be.
The x402 v2 specification names sepa:eu as an example network but standardizes no fiat scheme, so this project defines one: sepa-mandate. The standing authorization is the mandate (Mandates); the per-payment proof is a signature over { from, to, value, asset, validBefore, nonce } plus the payout id and mandate reference, keyed by X402_PAYER_SECRET.
The facilitator sits on the payer's side: on SEPA only the mandate holder can move the money.
The handshake#
The three headers are base64 JSON. Below they are shown decoded. Everything on this page was captured from a running deployment with the simulated rail on.
The seller says 402#
GET /report — no payment attached#
Response 402, header PAYMENT-REQUIRED:
{
"x402Version": 2,
"resource": {
"url": "http://127.0.0.1:4604/report",
"description": "Q3 market report",
"mimeType": "application/json"
},
"accepts": [
{
"scheme": "sepa-mandate",
"network": "sepa:eu",
"amount": "1.00",
"asset": "EUR",
"payTo": "DE89370400440532013000",
"maxTimeoutSeconds": 300,
"extra": {
"payeeName": "Report Vendor BV",
"reference": "x402 Q3 market report"
}
}
]
}The body carries the same object for humans. Nothing has moved. quote_x402_resource { url } reads this and commits nothing.
The facilitator#
GET/x402/supported
What this deployment can verify and settle. No key needed. Empty on a deployment with no rail, so that no seller serves a resource it can never be paid for.
With the simulated rail:
{
"kinds": [
{
"x402Version": 2,
"scheme": "sepa-mandate",
"network": "sepa:eu",
"extra": {
"settlement": "sepa-simulation",
"simulated": true,
"environment": "SANDBOX",
"amountFormat": "decimal-2dp"
}
}
],
"extensions": [],
"signers": {}
}Without a rail:
{ "kinds": [], "extensions": [], "signers": {} }POST/x402/verify
The seller sends the decoded PAYMENT-SIGNATURE payload together with the requirement it published. Nothing moves. The checks, in order: signature intact; not expired; matches the requirement (payee, amount, asset); the payout exists, is approved and not already settled; the payout's beneficiary is the seller's IBAN; the mandate still validates for it.
Request
{
"x402Version": 2,
"paymentPayload": {
"x402Version": 2,
"resource": {
"url": "http://127.0.0.1:4604/report",
"description": "Q3 market report"
},
"accepted": {
"scheme": "sepa-mandate",
"network": "sepa:eu",
"amount": "1.00",
"asset": "EUR",
"payTo": "DE89370400440532013000",
"maxTimeoutSeconds": 300,
"extra": {
"payeeName": "Report Vendor BV",
"reference": "x402 Q3 market report"
}
},
"payload": {
"payoutId": "b1f75ef4-c754-4a58-8d18-bdbf0042e453",
"mandateReference": "X402-001",
"authorization": {
"from": "NL91ABNA0417164300",
"to": "DE89370400440532013000",
"value": "1.00",
"asset": "EUR",
"validBefore": "1790191414",
"nonce": "4f2c9a1e7b3d"
},
"signature": "sepa-mandate-sig-075e231d6fb6abd4736f9658e33c47741607c6b0e9722a2779607bf7f6d85069"
}
},
"paymentRequirements": {
"scheme": "sepa-mandate",
"network": "sepa:eu",
"amount": "1.00",
"asset": "EUR",
"payTo": "DE89370400440532013000",
"maxTimeoutSeconds": 300,
"extra": {
"payeeName": "Report Vendor BV",
"reference": "x402 Q3 market report"
}
}
}Response 200
{
"isValid": true,
"payer": "NL91ABNA0417164300"
}With the value edited to 5.00 after signing:
{
"isValid": false,
"invalidReason": "Authorization signature does not verify.",
"payer": "NL91ABNA0417164300"
}POST/x402/settle
Same request body as verify. Verifies again, then executes the payout through the rail.
Response 200
{
"success": true,
"transaction": "SIM-37F5A5A9",
"network": "sepa:eu",
"payer": "NL91ABNA0417164300",
"settlement": "sepa-simulation",
"simulated": true
}Settle is idempotent for the same signed payload — asking again returns the same transaction, so a seller that got no answer can retry and serve. While a settlement is still pending it answers success: false with errorReason starting Settlement pending:; settle again later, do not re-pay. On a deployment with no rail it answers success: false and says it does not settle.
A seller written in Python calls the facilitator with client.x402: supported(), verify(payment_payload=..., payment_requirements=...) and settle(...). They never raise on is_valid: false or success: false; read the fields, and when result.pending is True, settle again later.
The seller serves#
GET /report — with PAYMENT-SIGNATURE#
The seller called verify, then settle, then served.
Response 200, header PAYMENT-RESPONSE:
{
"success": true,
"transaction": "SIM-37F5A5A9",
"network": "sepa:eu",
"payer": "NL91ABNA0417164300",
"settlement": "sepa-simulation",
"simulated": true
}Body:
{
"report": "Q3 market report",
"generatedAt": "2026-09-23T19:18:34.381Z",
"paidBy": "NL91ABNA0417164300",
"settlement": {
"network": "sepa:eu",
"transaction": "SIM-37F5A5A9",
"simulated": true
},
"summary": "This is the paid resource. It was served after the facilitator settled the payment in simulation; no real money moved."
}The seller saw: 402 → payment-signature → served.
The reference seller settles before serving. The spec's order is verify → serve → settle, which works when a chain has already locked the funds; a bank transfer is not pre-funded, so serving first would extend credit. With no rail it can serve on verification alone (serve: 'after-verification') and says so in the body.
The payer side#
quote_x402_resource { url }— read the 402; commits nothing.pay_x402_resource { url, mandateId }— checks the mandate for the quoted amount and payee, creates the payout, waits for the KYC decision, signs, retries the request. Refused by the mandate → nothing is created. Offered only on deployments that can settle.
The signature is the commitment; everything before it is reversible.
From Python, the same two steps are client.x402.quote and client.x402.pay:
quote = await client.x402.quote(url="https://vendor.example/report")
if not quote.free:
paid = await client.x402.pay(url=quote.url, mandate_id=mandate.mandate_id, reason="Q3 report")
print(paid.paid, paid.status, paid.payout_id)Try it#
npm run x402:walkthrough # no rail: the seller serves on verification
SETTLEMENT_RAIL=simulation npm run x402:walkthrough # simulated rail: settles, then servesEach run ends with the mandate revoked and the same request refused before anything is signed.