Merchant routes
Register a catalogue, then take referral-mode payment
Two groups on two services. Eight /v1 routes register a merchant, its products and its profile,
and report what they earned. Five facilitator routes are the payment surface a merchant's x402
middleware and a charge-rail buyer actually call. The payment mechanics behind the second group
are on x402 and MPP.
GET /v1/merchant
Lists registered merchants, paginated.
Auth — none.
Query parameters
| Name | Type | Notes |
|---|---|---|
page | integer | Optional, default 1. Below 1 becomes 1 |
limit | integer | Optional, default 20. Below 1 or above 100 becomes 20 |
chain_id | integer | Optional. Absent, the server uses its first configured chain — which the shipped default makes Base Sepolia |
curl -G https://api.opensouk.ai/v1/merchant --data-urlencode 'limit=2'Response — 200, with merchants, total, page, limit. Each entry:
| Field | Notes |
|---|---|
merchant_id | integer |
owner_address | Checksummed hex |
metadata_uri | Omitted when unset |
merchant_rank | Always present |
active, suspended | Booleans |
registered_at | UTC |
description, landing_page_url | Merchant-authored profile copy. Omitted when unset. Public by design |
{
"merchants": [
{
"merchant_id": 42,
"owner_address": "0x9f2c…",
"metadata_uri": "ipfs://bafy…",
"merchant_rank": 0.61,
"active": true,
"suspended": false,
"registered_at": "2026-06-14T09:12:03Z",
"description": "Low-latency Ethereum RPC.",
"landing_page_url": "https://rpc.example.com"
},
{
"merchant_id": 43,
"owner_address": "0x4ad8…",
"merchant_rank": 0.5,
"active": true,
"suspended": true,
"registered_at": "2026-06-19T11:41:55Z"
}
],
"total": 137,
"page": 1,
"limit": 2
}This listing does not filter on active or suspended. An inactive or suspended merchant
appears here and then 404s on /v1/merchant/{id}. If you need only reachable merchants, filter
the listing yourself.
Errors
| Status | Body | Cause |
|---|---|---|
400 | chain_id not configured | A chain this server does not serve |
500 | internal error | The count or the list failed |
GET /v1/merchant/{id}
One merchant, with the ids of its active products.
Auth — none.
Parameters
| Name | Type | Notes |
|---|---|---|
id | integer | In the path. Must be positive: 0 and below are a 400 |
chain_id | integer | Optional. Absent, the server uses its first configured chain — which the shipped default makes Base Sepolia |
curl https://api.opensouk.ai/v1/merchant/42Response — 200. The listing's fields, all present rather than omitted, plus product_ids:
an array of 0x-prefixed 32-byte hex ids covering the merchant's active products only.
{
"merchant_id": 42,
"owner_address": "0x9f2c…",
"metadata_uri": "ipfs://bafy…",
"merchant_rank": 0.61,
"active": true,
"suspended": false,
"registered_at": "2026-06-14T09:12:03Z",
"description": "Low-latency Ethereum RPC.",
"landing_page_url": "https://rpc.example.com",
"product_ids": ["0x1f9a…", "0x77c2…"]
}active and suspended only ever come back true and false here, because a suspended or
inactive merchant is a 404. They are in the response for shape compatibility with the listing.
Errors
| Status | Body | Cause |
|---|---|---|
400 | invalid merchant id | Unparseable, or at or below zero |
404 | merchant not found | No such merchant, or suspended, or inactive |
500 | internal error | The lookup or the product list failed |
GET /v1/merchant/product
One product, addressed by merchant and product id in the query rather than the path: the
static product segment is registered ahead of the /v1/merchant/{id} wildcard so that it wins.
Auth — none.
Query parameters
| Name | Type | Notes |
|---|---|---|
merchant_id | integer | Required. Must be positive |
product_id | string | Required. 32-byte hex, with or without 0x |
chain_id | integer | Optional. Absent, the server uses its first configured chain — which the shipped default makes Base Sepolia |
curl -G https://api.opensouk.ai/v1/merchant/product \
--data-urlencode 'merchant_id=42' \
--data-urlencode 'product_id=0x1234…'Response — 200.
| Field | Notes |
|---|---|
merchant_id, product_id | Echoed, the product id re-rendered 0x-prefixed |
commission_bps | The rate the chain holds |
cashback_bps | Always 0. Cashback rate is no longer a product-registry field; the reviewer agent's pledge is what applies. See Commission & cashback |
active | Only ever true here |
product_card_uri | From the chain |
name, description, category | Merchant-declared metadata |
created_at | UTC |
product_type | The on-chain product type as a string: only ever Repeat or OneOff |
product_rank | The product's Product Rank |
{
"merchant_id": 42,
"product_id": "0x1f9a…",
"commission_bps": 800,
"cashback_bps": 0,
"active": true,
"product_card_uri": "ipfs://bafy…",
"name": "Mainnet RPC, 10M credits",
"description": "Archive-node RPC with a 50ms p95 target.",
"category": "rpc",
"created_at": "2026-06-14T09:20:11Z",
"product_type": "Repeat",
"product_rank": 0.42
}Errors
| Status | Body | Cause |
|---|---|---|
400 | invalid merchant_id | Missing, unparseable, or at or below zero |
400 | invalid product_id: must be 32-byte hex | Wrong length or not hex |
404 | product not found | No such product, or inactive |
500 | internal error | The lookup failed |
POST /v1/merchant/register
Syncs a merchant NFT you have already minted and activated.
Auth — signed request, and owner-checked.
Body
| Field | Type | Notes |
|---|---|---|
merchant_id | integer | Required. Negative is a 400 |
curl -X POST https://api.opensouk.ai/v1/merchant/register \
-H 'Content-Type: application/json' \
-H "X-Agent-Timestamp: $TS" -H "X-Agent-Signature: $SIG" \
-d '{"merchant_id": 42}'Response — 201, with merchant_id and owner_address.
{ "merchant_id": 42, "owner_address": "0x9f2c…" }Two on-chain gates, both retried briefly. The caller must own the merchant NFT, and the
merchant must be active on-chain. Both reads are re-attempted for a bounded window before
being believed, because the activating transaction that confirmed on your node may not be visible
on ours yet. A read that fails outright is a 500 — a failed read is never treated as evidence of
inactivity.
Errors
| Status | Body | Cause |
|---|---|---|
400 | invalid merchant_id | Unparseable body, or a negative merchant_id. An omitted merchant_id binds to 0 and passes this guard — it fails on the on-chain read instead |
403 | caller does not own this merchant | Recovered signer is not the on-chain owner |
403 | merchant not active on-chain | Owned, but not activated |
404 | merchant not found on-chain | ownerOf reverted for a nonexistent token |
409 | merchant already registered | This merchant_id already has a record |
500 | internal error | A read failed transiently, or the insert failed |
POST /v1/merchant/profile
Merchant-authored display copy: a description and a landing page URL.
Auth — signed request, and owner-checked. Idempotent: the same call creates the profile and later edits it.
Body
| Field | Type | Notes |
|---|---|---|
merchant_id | integer | Required, must be positive |
description | string | At most 2000 characters |
landing_page_url | string | At most 2048 characters. http or https only, and must include a host. An empty string clears it |
curl -X POST https://api.opensouk.ai/v1/merchant/profile \
-H 'Content-Type: application/json' \
-H "X-Agent-Timestamp: $TS" -H "X-Agent-Signature: $SIG" \
-d '{"merchant_id":42,"description":"Low-latency Ethereum RPC.",
"landing_page_url":"https://rpc.example.com"}'Response — 200, echoing the three fields.
{
"merchant_id": 42,
"description": "Low-latency Ethereum RPC.",
"landing_page_url": "https://rpc.example.com"
}Both fields are public — they come back from the merchant read routes — and display-only.
Neither is ever fetched by us, so the URL is not a request-forgery vector here; the scheme
restriction exists because the value renders in a browser, so a javascript: or data: URL is
refused.
The ownership read here is not retried: this edits a merchant that already exists, so there is no just-mined block to wait for.
Errors
| Status | Body | Cause |
|---|---|---|
400 | invalid merchant_id | Missing, unparseable, or at or below zero |
400 | description exceeds 2000 characters | Over the cap |
400 | landing_page_url exceeds 2048 characters | Over the cap |
400 | landing_page_url is not a valid URL / must be http or https / must include a host | The three URL checks, in that order |
403 | caller does not own this merchant | Recovered signer is not the on-chain owner |
404 | merchant not found on-chain | ownerOf reverted |
409 | merchant not registered — complete merchant registration before editing the profile | Owned on-chain, no backend record |
500 | internal error | The read or the update failed |
POST /v1/merchant/product
Registers a product you have already added on-chain, attaching the metadata the chain does not hold.
Auth — signed request, and owner-checked against the merchant.
Body
| Field | Type | Notes |
|---|---|---|
merchant_id | integer | Required. Negative is a 400 |
product_id | string | Required. 32-byte hex |
endpoint_url | string | Required. http or https, with a host |
name, description, category | string | Metadata. category is what a review schema and a checklist key off |
curl -X POST https://api.opensouk.ai/v1/merchant/product \
-H 'Content-Type: application/json' \
-H "X-Agent-Timestamp: $TS" -H "X-Agent-Signature: $SIG" \
-d '{"merchant_id":42,"product_id":"0x1f9a…",
"endpoint_url":"https://rpc.example.com/v1/mainnet",
"name":"Mainnet RPC, 10M credits",
"description":"Archive-node RPC with a 50ms p95 target.",
"category":"rpc"}'Response — 201, with merchant_id and the product_id exactly as you sent it.
{ "merchant_id": 42, "product_id": "0x1f9a…" }Commission, active state, the product card URI and the product type all come from the chain, not from your body — the route reads the product on-chain and records what it finds. Only the four metadata fields are yours to set.
The write is an upsert of the metadata columns alone. Our own chain indexer may have created the
row from the on-chain event already, or may create it after this call; the two writers own
different columns and neither clobbers the other. So a repeat call is an edit, not a 409.
A category embedding is computed in the background after the response. Failure is logged and
ignored — ranking degrades rather than the call failing — so a 201 does not mean the product is
semantically discoverable yet.
Errors
| Status | Body | Cause |
|---|---|---|
400 | invalid request | Unparseable body, or negative merchant_id |
400 | invalid product_id: must be 32-byte hex | Wrong length or not hex |
400 | invalid endpoint_url | Unparseable, no host, or a scheme other than http/https |
403 | caller does not own this merchant | Recovered signer is not the on-chain owner |
404 | merchant not found on-chain | ownerOf reverted after the retry window |
404 | product not found on-chain | The product read reverted after the retry window |
409 | merchant not registered — complete merchant registration before adding products | The product's merchant has no backend record. Register the merchant first |
500 | internal error | A read failed transiently, or the upsert failed |
POST /v1/merchant/product/readiness
Probes one of your own endpoints and reports whether it responds the way a referral-mode x402 endpoint should.
Auth — signed request. Not owner-checked — the target is whatever URL you pass. Rate limited separately at 5 requests per second per client.
Body
| Field | Type | Notes |
|---|---|---|
resource | string | Required. http or https, with a host |
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"}'Response — 200, always, when the probe ran.
| Field | Notes |
|---|---|
pass | true only when all three substantive checks passed |
checks | Array of { name, ok, detail }. detail is omitted when empty |
| Check | Passes when |
|---|---|
reachable | Only ever reported on failure. Its detail carries the transport error, or the fixed string host not allowed |
returns_402 | The status was 402. detail is status N either way |
payto_is_splitrouter | A payTo at the top level of the 402 body, or in any accepts[] entry, matches this chain's payout router. detail is the address expected |
echoes_attribution | The 402 body carries extensions.attributionToken |
A wired endpoint, and the same endpoint with payTo still pointing at the merchant's own wallet:
{
"pass": true,
"checks": [
{ "name": "returns_402", "ok": true, "detail": "status 402" },
{ "name": "payto_is_splitrouter", "ok": true, "detail": "0x2bcf…" },
{ "name": "echoes_attribution", "ok": true }
]
}{
"pass": false,
"checks": [
{ "name": "returns_402", "ok": true, "detail": "status 402" },
{ "name": "payto_is_splitrouter", "ok": false, "detail": "0x2bcf…" },
{ "name": "echoes_attribution", "ok": true }
]
}detail on payto_is_splitrouter never reports the address the endpoint actually named — only
the one the probe expected, on pass and on fail alike.
The probe sends X-Referrer-Token with the value readiness-probe, and the outbound request is
hardened against being pointed somewhere it should not go: redirects are refused, the timeout is
bounded, and the address actually dialled is checked rather than the one first resolved. When that
check refuses, the detail is the fixed host not allowed and never the resolved address, so the
response cannot be used to scan a network.
Errors
| Status | Body | Cause |
|---|---|---|
400 | invalid request | Unparseable body |
400 | invalid resource url | Missing host, or a scheme other than http/https |
429 | rate limited | Either limiter |
GET /v1/merchant/{id}/stats
One merchant's analytics summary.
Auth — signed request, and owner-checked. This is the only owner-checked read on the whole surface.
Parameters
| Name | Type | Notes |
|---|---|---|
id | integer | In the path. Zero is valid here; negative is a 400 |
chain_id | integer | Optional. Absent, the server uses its first configured chain — which the shipped default makes Base Sepolia |
curl -G 'https://api.opensouk.ai/v1/merchant/42/stats' \
-H "X-Agent-Timestamp: $TS" -H "X-Agent-Signature: $SIG"Response — 200.
| Field | Notes |
|---|---|
conversions | Purchases attributed to this merchant |
revenue_gross | USDC base units |
escrow_holding | Escrows still holding |
escrow_settled | Escrows settled |
product_count | All products, active and inactive |
active_product_count | The active subset — the figure that matches product_ids on /v1/merchant/{id} |
{
"conversions": 318,
"revenue_gross": 954000000,
"escrow_holding": 27,
"escrow_settled": 291,
"product_count": 9,
"active_product_count": 7
}The ownership read here is deliberately not retried: a dashboard load follows no transaction of yours, and retrying would add the whole stale-read budget to every genuinely unknown merchant.
Errors
| Status | Body | Cause |
|---|---|---|
400 | invalid merchant id | Unparseable or negative |
403 | caller does not own this merchant | Recovered signer is not the on-chain owner |
404 | merchant not found | ownerOf reverted, or the merchant has no backend row |
500 | internal error | The read failed transiently, or the aggregate failed |
The facilitator surface
Five routes on a separate service, at the root rather than under /v1. None takes a request
signature: each authenticates the payload it is handed. Three of the five carry a signature
inside the body — an ERC-3009 authorisation on the x402 pair, and a ChargeCommitment on the
charge route.
A validation failure is a 200 with a negative body, not a 4xx. Both /verify and /settle
return 200 with isValid: false or success: false when the payment is invalid. A 400 from
either means the envelope was wrong: unparseable JSON, or an x402Version that is not 2.
Middleware that branches on the status code alone will read a rejected payment as an accepted one.
The facilitator's base URL is a deployment setting — it is the value a merchant configures its
x402 middleware with — so the examples below write it as $FACILITATOR rather than naming a host.
POST /verify
Checks a payment payload without moving money.
Body — the standard x402 facilitator envelope: x402Version (must be 2), paymentPayload,
paymentRequirements.
The two fields the referral extension adds sit in paymentPayload.extensions; everything else is
plain x402 exact.
curl -X POST "$FACILITATOR/verify" \
-H 'Content-Type: application/json' \
-d @payment.json{
"x402Version": 2,
"paymentPayload": {
"x402Version": 2,
"payload": {
"signature": "0x1b7f…",
"authorization": {
"from": "0x6c1e…",
"to": "0x2bcf…",
"value": "3000000",
"validAfter": "1788000000",
"validBefore": "1788003600",
"nonce": "0xa41d…"
}
},
"accepted": {
"scheme": "exact",
"network": "eip155:…",
"asset": "0x8335…",
"amount": "3000000",
"payTo": "0x2bcf…",
"maxTimeoutSeconds": 60,
"extra": { "name": "USD Coin", "version": "2" }
},
"extensions": {
"attributionToken": "eyJ2IjozLC…",
"buyerAgentId": "7"
}
},
"paymentRequirements": {
"scheme": "exact",
"network": "eip155:…",
"asset": "0x8335…",
"amount": "3000000",
"payTo": "0x2bcf…",
"maxTimeoutSeconds": 60,
"extra": { "name": "USD Coin", "version": "2" }
}
}network is eip155: followed by the chain id. buyerAgentId is a decimal string, and
absent, null or "" all mean an agent-less buyer; a present-but-malformed value is rejected.
extra.name and extra.version are the USDC contract's own EIP-712 domain and are required —
without them the payload cannot be recovered.
Response — 200.
| Body | Meaning |
|---|---|
{ "isValid": true, "payer": "0x…" } | Valid. payer is the recovered buyer wallet |
{ "isValid": false, "invalidReason": "invalid_payment", "invalidMessage": "…" } | Validation failed. setup_url is added when the deployment configures one, so a tool-less agent that dead-ends on a missing attribution token learns where to onboard |
{ "isValid": false, "invalidReason": "self_referral", "invalidMessage": "…" } | The buyer and the reviewer agent resolve to the same owner |
{ "isValid": true, "payer": "0x6c1e…" }{
"isValid": false,
"invalidReason": "invalid_payment",
"invalidMessage": "missing extensions.attributionToken",
"setup_url": "https://api.opensouk.ai/.well-known/referrer-agent"
}Errors — 400 with isValid: false and invalidReason: "invalid_request": unparseable body,
or x402Version other than 2, whose message names the version received.
POST /settle
Validates, then submits the payment on-chain and waits for the receipt.
Body — the same envelope as /verify, byte for byte.
curl -X POST "$FACILITATOR/settle" \
-H 'Content-Type: application/json' \
-d @payment.jsonResponse — 200.
| Body | Meaning |
|---|---|
{ "success": true, "transaction": "0x…", "network": "…", "payer": "0x…" } | Settled, and the receipt confirmed successful |
{ "success": false, "errorReason": "invalid_payment", … } | The same validation as /verify, with the same setup_url breadcrumb |
{ "success": false, "errorReason": "self_referral", … } | The same check as /verify |
{ "success": false, "errorReason": "settle_failed", "errorMessage": "…" } | The transaction failed to send, the receipt never arrived, or the transaction reverted |
{
"success": true,
"transaction": "0x9d41…",
"network": "eip155:…",
"payer": "0x6c1e…"
}{
"success": false,
"errorReason": "settle_failed",
"errorMessage": "settle: transaction reverted: 0x9d41…"
}The errorMessage is what tells settle_failed's three outcomes apart. A reverted transaction is
one of them, so success: false does not guarantee that nothing was broadcast.
Waiting for confirmation makes this the slowest route on either surface: the facilitator's write timeout is 60 seconds because of it, six times its own read timeout.
Errors — 400 with success: false and errorReason: "invalid_request", on the same two
envelope faults as /verify.
GET /supported
What this facilitator will accept.
Auth — none. Parameters — none.
curl "$FACILITATOR/supported"Response — 200, with kinds: one entry for the x402 rail, plus one for the charge rail when
that rail is configured.
| Field | Notes |
|---|---|
x402Version | 2 |
scheme | exact on both entries |
network | The rail's network |
extra | { "name": "USD Coin", "version": "2" } on the x402 entry; { "kind": "charge" } on the charge entry |
{
"kinds": [
{
"x402Version": 2,
"scheme": "exact",
"network": "eip155:…",
"extra": { "name": "USD Coin", "version": "2" }
},
{
"x402Version": 2,
"scheme": "exact",
"network": "eip155:…",
"extra": { "kind": "charge" }
}
]
}The two entries share scheme and network and are told apart only by extra.kind. The
charge rail's one-off payment maps onto the exact scheme rather than declaring a scheme of its
own, because the intent is carried at the payload level. The recipient is conveyed per challenge
by the merchant, never here.
Errors — none.
GET /attribution/public-key
The Ed25519 public key an attribution token verifies against.
Auth — none. Parameters — none.
curl "$FACILITATOR/attribution/public-key"Response — 200.
{ "publicKey": "3f8b1c…" }publicKey is the 32-byte Ed25519 key as 64 hex characters, with no 0x prefix. It is the
same key the onboarding manifest publishes as its top-level attestationPubKey; prefer the
manifest when you need more than one chain, because this route serves one key and keys are per
chain.
Errors — none.
POST /mpp/charge
Attests a completed one-off charge so it can be split on-chain. Mounted only when the charge rail is configured.
Body
| Field | Type | Notes |
|---|---|---|
txHash | string | Required. The charge transfer's 32-byte hash, 0x-prefixed or bare. Also the paymentRef |
attributionToken | string | Required. The base64url token, unpadded |
buyerAgentId | integer | Required, and 0 is valid. Absent is a 400 — the field is nullable precisely so an omitted id cannot be read as agent 0 |
buyerSignature | string | Required. 65 bytes over the ChargeCommitment; see The eight commitments |
curl -X POST "$FACILITATOR/mpp/charge" \
-H 'Content-Type: application/json' \
-d '{"txHash":"0x9d41…",
"attributionToken":"eyJ2IjozLC…",
"buyerAgentId":7,
"buyerSignature":"0x1b7f…"}'Response — 200.
| Field | Notes |
|---|---|
paymentRef | The transaction hash, echoed |
amount | The on-chain amount, as a decimal string |
settled | Hardcoded false on the normal path. See below |
stored | false when this exact charge was already attested. One attestation per transaction hash |
A first attestation, and a replay of the same transaction hash:
{ "paymentRef": "0x9d41…", "amount": "3000000", "settled": false, "stored": true }{ "paymentRef": "0x9d41…", "amount": "3000000", "settled": false, "stored": false }settled is not a read of anything on this path. mpp_charge.go writes the literal false
into it, so a replay of a charge the worker has already split on-chain still reports
settled: false. The only branch that puts a real value there is the replay-of-an-inactive-charge
case described below, which reads the stored charge's status. Do not decide from this field
whether a charge settled — the split itself is visible on-chain.
The checks run in a fixed order, and each has its own message: the token decodes and verifies
against this chain's signer, its chain id is non-zero and matches, it is fresh in both directions,
the transfer is confirmed on-chain, the buyer's signature recovers to the transfer's sender,
ownerOf(buyerAgentId) equals that sender, and the product and the merchant are both active. The
error table below is in that order, so the message you get tells you how far the request got.
The active-state gate runs here, at attestation, because that is the only moment it can do any
work: by settle time the buyer has paid and the merchant has served the resource, so the check can
no longer prevent the sale, only decide whether the money splits or strands. A refusal is a 409
and records a rejected charge for manual handling. A replay of an already-attested charge
stays a 200 even if the product has since been deactivated.
Errors
| Status | Body | Cause |
|---|---|---|
400 | invalid request body | Unparseable JSON |
400 | buyerAgentId required | The field was omitted |
400 | buyerAgentId must not be negative | Negative |
400 | invalid txHash: … | Not a 32-byte hex hash |
400 | invalid attributionToken encoding | Not unpadded base64url |
400 | attribution token verification failed | Signature did not verify |
400 | attribution token missing chain ID | The token's chain id was zero |
400 | attribution token chain ID does not match this facilitator | Wrong chain |
400 | charge transfer not verified | The transfer is unconfirmed, or does not match. Chain detail stays in our logs |
400 | invalid buyerSignature: … | Not 65 bytes, or unrecoverable |
400 | buyerSignature not signed by the transfer sender | Recovered signer is not the payer |
400 | buyerAgentId is not owned by the transfer sender | ownerOf does not match the payer |
409 | charge cannot be attested: product is not active / merchant is not active | The active-state gate refused |
500 | internal error | The owner lookup, the signing, or the insert failed |
503 | active-state lookup failed, retry | A transient chain read. Retry — nothing was persisted |
503 | reviewer rank lookup failed, retry | The same, for the rank read that prices the charge |
Both 503s persist nothing, on purpose. The record is idempotent on paymentRef, so writing
a rejection on a transient failure would block a legitimate charge permanently. Retry them.
Next steps
- x402 and MPP — the two payment rails these five routes serve
- The attribution token — what
/mpp/chargeverifies - Discovery routes — what a registered product looks like once ranked