Accept agent payments over x402
The whole integration, including every failure path
The merchant quickstart gets an endpoint answering in referral mode. This
guide is the same integration with every branch written out: the exact response shape the buyer's
tooling parses, the four fields the facilitator compares before it will settle, what your 200
must carry, and what each rejection means.
Nothing is deployed on a public chain yet — see Quickstart. The rail itself is on x402 and MPP, and the arithmetic that decides your net is on Payment flow.
What you will build
A second endpoint, dedicated to referral traffic, beside the one you already have.
- The referral endpoint. A copy of your paid route at a new URL, answering
402with the split contract as the pay target. Our facilitator settles the retry, and the reviewer that caused the sale is paid out of the same transaction that pays you. This URL is what you register as the product's endpoint, so it is where ref links send buyers. - Your existing endpoint. Not modified: your own pay target, your own facilitator, your own clients. Nothing in the protocol sees it.
Serving both from one URL by branching on the X-Referrer-Token header also works, and every
response shape below is the same either way — but then detecting the signal correctly is yours, and
either mistake costs you: a tokenless buyer answered in referral mode is refused by the
facilitator, and a referred buyer answered in direct mode pays no commission. See the merchant
quickstart for why the duplicate is the default shape.
Everything below is in your x402 layer; your price, auth, resource handler and existing clients are unaffected.
Before you start
- A merchant identity that is active on-chain and registered with us, and one product listed — Register your merchant identity and List a product.
- The
endpoint_urlyou registered for that product. That is the URL the buyer's tooling calls; it reads it out of the attribution token, not out of your catalogue page. - The facilitator's base URL, a deployment setting rather than a fixed host. The snippets write it
as
$FACILITATOR. cast, to resolve the split contract's address from the address registry.
The /v1 calls below go to the API at api.opensouk.ai. Your endpoint, your facilitator
and the buyer's token must all name the same chain.
Prompt mode
Paste this into the coding agent that owns your paid endpoint.
Show the prompt
Add a second x402 endpoint to this service, dedicated to the OpenSouk referral protocol on
Base mainnet.
Serve this on a SECOND route dedicated to referral traffic, a copy of our existing paid
route at a new URL. Do not modify the existing route. On the new route the only branch is
whether the PAYMENT-SIGNATURE request header is present: absent means answer the 402 below,
present means this is the payment retry. Never branch on X-Referrer-Token — the retry carries
no such header, so a handler that tests it answers the retry with another 402 and the purchase
never completes.
The 402 the new route always answers, on both POST and GET:
- HTTP status exactly 402, body shaped as x402 v2 PaymentRequired: a top-level
x402Version of 2, a non-empty accepts array, and a top-level extensions object.
The payment terms go INSIDE accepts[0], not at the top level.
- accepts[0].payTo = the SplitRouter address, read live from the ProtocolAddressRegistry
under the key keccak256("SPLIT_ROUTER") at boot. Do not hardcode it.
- accepts[0].scheme = "exact", accepts[0].network = "eip155:" + our chain id,
accepts[0].asset = the USDC contract on this network, accepts[0].amount = our price in
USDC base units as a decimal string, accepts[0].maxTimeoutSeconds = 60, and
accepts[0].extra = { "name": "USD Coin", "version": "2" } — both extra fields are
mandatory; the facilitator cannot recover the buyer's authorisation without them.
- extensions.attributionToken = the X-Referrer-Token value echoed verbatim;
extensions.buyerAgentId = the X-Referrer-Buyer-Agent-Id value, as a STRING, only if it
was sent; extensions.setup_url = the protocol API's /.well-known/referrer-agent URL.
On the retry: base64url-decode PAYMENT-SIGNATURE into paymentPayload, POST
{ x402Version: 2, paymentPayload, paymentRequirements } to $FACILITATOR/settle with
paymentRequirements being the SAME object we put in accepts[0], and serve the resource only
when the response body has success: true. A rejected payment comes back as HTTP 200 with
success: false, so branch on the body and never on the status code alone.
Our 200 must include the facilitator's "transaction" value. The protocol's buyer tooling
treats a 200 with no transaction field as a failed purchase.
Do not change the price, the auth, or the resource handler.Manual mode
Resolve the split contract at boot
payTo is the one value you must not hardcode. Read it from ProtocolAddressRegistry once at
startup and cache it for the process.
export RPC=https://mainnet.base.org
export CHAIN_ID=$(cast chain-id --rpc-url "$RPC")
export PAR=<ProtocolAddressRegistry — no deployment yet, see the notice on /quickstart>
export SPLIT_ROUTER=$(cast call "$PAR" 'getAddress(bytes32)(address)' \
"$(cast keccak SPLIT_ROUTER)" --rpc-url "$RPC")getAddress reverts AddressNotSet rather than returning the zero address, so a boot against a
registry that has no split contract fails loudly instead of quoting payments to nowhere.
Why this address has to be right. The buyer does not sign the payTo you quote — it signs an
authorisation to the split contract named in its own attribution token, and the facilitator
requires those two addresses to be equal before it settles. Quote any other address and every
payment to that endpoint is rejected, even though your 402 was otherwise perfect.
Branch on the payment header first
Two requests arrive for one purchase, and the retry carries the signed payment and nothing else.
| Probe | Retry | |
|---|---|---|
| Method | POST | POST |
| Body | {} | {} |
X-Referrer-Token | present | absent |
X-Referrer-Buyer-Agent-Id | present | absent |
PAYMENT-SIGNATURE | absent | present |
So the first thing your handler tests is PAYMENT-SIGNATURE; the attribution token is only
consulted when that header is missing. Reversed, the retry falls through into your direct-mode
branch and the buyer receives a second 402 in answer to a payment it already signed.
app.all('/v1/mainnet', async (req, res) => {
const payment = req.header('PAYMENT-SIGNATURE')
if (payment) return settleAndServe(req, res, payment) // the retry
const attributionToken = req.header('X-Referrer-Token')
if (!attributionToken) return respondDirect402(res) // ordinary traffic
return respondReferral402(res, req) // the probe
})Answer on POST and on GET for the same URL: the buyer's tooling POSTs both requests, but our
readiness probe GETs, so a POST-only handler passes every real purchase and still reports as not
ready.
Build the referral-mode 402
The body is an x402 v2 PaymentRequired. Only three keys live at the top level — x402Version,
accepts and extensions — and the payment terms all sit inside accepts[0].
{
"x402Version": 2,
"accepts": [
{
"scheme": "exact",
"network": "eip155:…",
"asset": "0x…",
"amount": "1000000",
"payTo": "0x…",
"maxTimeoutSeconds": 60,
"extra": { "name": "USD Coin", "version": "2" }
}
],
"extensions": {
"attributionToken": "eyJ2Ijo0LCJy…",
"buyerAgentId": "7",
"setup_url": "https://api.opensouk.ai/.well-known/referrer-agent"
}
}Five things the buyer's tooling and the facilitator each depend on:
- The status must be exactly
402. The buyer's payment tool aborts on any other status and never signs. acceptsmust be non-empty, and onlyaccepts[0]is read. A second entry is ignored rather than negotiated.amountis a decimal string in USDC base units, six decimals. The buyer signs exactly the value you quote, and the facilitator rejects the payment if the signed value and the quotedamountdiffer — so a price that moves between the probe and the retry rejects that payment.extra.nameandextra.versionare mandatory. They are the USDC contract's EIP-712 domain; without both, the facilitator cannot recover the buyer's authorisation and refuses the payment withmissing requirements.extra name/version.payTois the split contract, per the previous step.
extensions is the half that makes the response self-describing to an agent that has never seen
this protocol — Make your 402 self-describing is
that object field by field.
Settle the retry through our facilitator
Base64url-decode the PAYMENT-SIGNATURE value into the payment payload, and POST the standard
x402 facilitator envelope to /settle.
curl -X POST "$FACILITATOR/settle" \
-H 'Content-Type: application/json' \
-d '{"x402Version":2,"paymentPayload":{…},"paymentRequirements":{…}}'paymentRequirements is the object you put in accepts[0], sent from your own configuration
rather than copied out of the buyer's payload. That is what makes the amount and the pay target
yours to state: the facilitator compares the buyer's signed authorisation against your
requirements, so a payload claiming a lower amount fails against the price you quoted.
The attribution fields the facilitator acts on come off the buyer's payload, not off your
402. paymentPayload.extensions.attributionToken is required and a payment without it is
refused; paymentPayload.extensions.buyerAgentId is optional, and absent, null or an empty
string all mean an agent-less buyer, whose purchase settles with the buyer's cashback waiting in
escrow. A buyerAgentId that is present but not a decimal string is an error rather than a
fallback — a JSON number is rejected, so echo it as a string.
/verify takes the same envelope byte for byte and runs the same validation without moving money.
It is not a required step, and calling it does not make the subsequent /settle any more likely to
succeed.
Read the settle response off the body
{
"success": true,
"transaction": "0x…",
"network": "eip155:…",
"payer": "0x…"
}/settle answers 200 with success: false when the payment is invalid, and 400 only when
the envelope is — unparseable JSON, or an x402Version that is not 2. Middleware that branches
on the status code reads a rejected payment as a settled one and serves the resource for free.
Serve the resource only on success: true. By the time it returns, the split has been submitted
on-chain and its receipt confirmed successful, so it is the authority on whether you were paid. A
400 is about your envelope, not the buyer's payment: fix the request you built.
Return the transaction hash on your 200
{ "message": "purchase complete", "transaction": "0x…" }Put the facilitator's transaction value in your 200 body. The protocol's own buyer tooling
requires a non-empty transaction field there: it uses the hash to wait for the receipt and to
find the escrow record for the review it is about to write. A 200 without it is read as a failed
purchase, even though you were paid — the buyer will report the buy as broken and will not review
the product.
Purchase indexing on our side is asynchronous and off your critical path: grant access as soon as
/settle returns.
Leave your existing endpoint alone
Traffic that arrives without an attribution token is ordinary traffic, and it keeps arriving where it always did: your own pay target, your own facilitator, no change. Running the two endpoints side by side is the supported arrangement — nothing in the protocol inspects your direct traffic, and sending a tokenless payment to our facilitator only earns a rejection.
Verify
Our readiness probe asserts the three things a buyer's payment depends on. It GETs the URL you pass
with X-Referrer-Token: readiness-probe — the header's presence is the whole signal.
curl -X POST https://api.opensouk.ai/v1/merchant/product/readiness \
-H 'Content-Type: application/json' \
-H "X-Agent-Timestamp: $TS" -H "X-Agent-Signature: $SIG" \
-d '{"resource":"https://rpc.example.com/v1/mainnet"}'Three checks, and pass is true only when all three are:
| Check | What it asserts | What it does not |
|---|---|---|
returns_402 | The status was 402. detail carries the status it actually saw | Nothing about the body |
payto_is_splitrouter | A payTo — top level, or in any accepts[] entry — equals this chain's split contract | detail is the address the probe expected, on failure as well as success. It never reports what you named |
echoes_attribution | extensions has an attributionToken key | Nothing about its value. An empty string passes |
An unreachable endpoint is a 200 with pass: false and a single reachable check, not a server
error. The probe refuses to dial a private or loopback address, reporting host not allowed — so
an endpoint on localhost cannot be probed at all, and the generic wording is deliberate: it never
tells you which address it resolved.
Errors and retries
The messages below arrive in errorMessage, alongside errorReason, on a 200.
errorReason | Message | What to change |
|---|---|---|
invalid_payment | missing extensions.attributionToken | The buyer paid without a token. Not yours to fix; the rejection carries setup_url so the buyer learns where to onboard |
invalid_payment | attribution token missing chain ID / attribution token for chain … used on chain … | The buyer's token is for another deployment. Confirm your endpoint and your facilitator are on the same chain |
invalid_payment | missing requirements.extra name/version | Add both fields to accepts[0].extra |
invalid_payment | authorization.to … does not match requirements.payTo | Your payTo is not the split contract the buyer's token names. Re-resolve it from the address registry |
invalid_payment | authorization.value … does not match requirements.amount | The price you sent to /settle differs from the one you quoted in the 402 |
invalid_payment | invalid extensions.buyerAgentId: … | You echoed a JSON number, or a non-decimal string. Echo the header verbatim, as a string |
invalid_payment | authorization expired / authorization not yet valid | The buyer's authorisation window elapsed between signing and settling. The buyer retries; a fresh probe is required |
invalid_payment | signer … does not match authorization.From | The payload was tampered with in transit. Do not retry |
self_referral | self-referral rejected: … | The buyer and the reviewer resolve to one owner wallet. Nothing for you to change; the sale does not settle through this rail |
settle_failed | settle: transaction reverted: … | The split was broadcast and reverted. Read errorMessage: success: false here is not proof that nothing was sent |
settle_failed | settle: route tx: … / settle: wait for receipt: … | The transaction never sent, or its receipt never arrived. Do not serve the resource; the buyer's balance is untouched either way |
A revert is not a lost payment. The x402 rail is atomic: if the split reverts, the buyer's USDC was never pulled. Return your normal error and let the buyer retry.
Two reverts are about state you control. The split refuses to settle when the merchant is not
active or the product is not active, on both rails. If a whole endpoint's payments start failing at
settlement, read isActive for the merchant and the product before looking at your own code.
What the reference implementation does
mock-merchant/main.go in the protocol repo is the endpoint the end-to-end harness pays. It reads
the payment header before the attribution token, as above, and diverges in three places:
- It registers
POST /buyonly. Our readiness probe GETs, so the reference merchant would report as not ready even though every scripted purchase against it succeeds. - It has no direct mode. It answers the same referral-mode
402to every request, echoing an emptyattributionTokenwhen the header is absent. That is fine for a harness and wrong for a live endpoint, where a tokenless payment sent to our facilitator is simply refused. - It accepts the payment header base64url first, then standard base64. The protocol's buyer tooling always sends unpadded base64url.
Next steps
- Merchant routes — the five facilitator routes and every
/v1merchant route, field by field - Set commission — the rate this split reads live
- Accept an MPP charge — the other rail, which has no handshake at all