Skip to content
OpenSouk

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

NameTypeNotes
pageintegerOptional, default 1. Below 1 becomes 1
limitintegerOptional, default 20. Below 1 or above 100 becomes 20
chain_idintegerOptional. 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'

Response200, with merchants, total, page, limit. Each entry:

FieldNotes
merchant_idinteger
owner_addressChecksummed hex
metadata_uriOmitted when unset
merchant_rankAlways present
active, suspendedBooleans
registered_atUTC
description, landing_page_urlMerchant-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

StatusBodyCause
400chain_id not configuredA chain this server does not serve
500internal errorThe count or the list failed

GET /v1/merchant/{id}

One merchant, with the ids of its active products.

Auth — none.

Parameters

NameTypeNotes
idintegerIn the path. Must be positive: 0 and below are a 400
chain_idintegerOptional. Absent, the server uses its first configured chain — which the shipped default makes Base Sepolia
curl https://api.opensouk.ai/v1/merchant/42

Response200. 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

StatusBodyCause
400invalid merchant idUnparseable, or at or below zero
404merchant not foundNo such merchant, or suspended, or inactive
500internal errorThe 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

NameTypeNotes
merchant_idintegerRequired. Must be positive
product_idstringRequired. 32-byte hex, with or without 0x
chain_idintegerOptional. 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…'

Response200.

FieldNotes
merchant_id, product_idEchoed, the product id re-rendered 0x-prefixed
commission_bpsThe rate the chain holds
cashback_bpsAlways 0. Cashback rate is no longer a product-registry field; the reviewer agent's pledge is what applies. See Commission & cashback
activeOnly ever true here
product_card_uriFrom the chain
name, description, categoryMerchant-declared metadata
created_atUTC
product_typeThe on-chain product type as a string: only ever Repeat or OneOff
product_rankThe 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

StatusBodyCause
400invalid merchant_idMissing, unparseable, or at or below zero
400invalid product_id: must be 32-byte hexWrong length or not hex
404product not foundNo such product, or inactive
500internal errorThe lookup failed

POST /v1/merchant/register

Syncs a merchant NFT you have already minted and activated.

Auth — signed request, and owner-checked.

Body

FieldTypeNotes
merchant_idintegerRequired. 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}'

Response201, 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

StatusBodyCause
400invalid merchant_idUnparseable 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
403caller does not own this merchantRecovered signer is not the on-chain owner
403merchant not active on-chainOwned, but not activated
404merchant not found on-chainownerOf reverted for a nonexistent token
409merchant already registeredThis merchant_id already has a record
500internal errorA 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

FieldTypeNotes
merchant_idintegerRequired, must be positive
descriptionstringAt most 2000 characters
landing_page_urlstringAt 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"}'

Response200, 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

StatusBodyCause
400invalid merchant_idMissing, unparseable, or at or below zero
400description exceeds 2000 charactersOver the cap
400landing_page_url exceeds 2048 charactersOver the cap
400landing_page_url is not a valid URL / must be http or https / must include a hostThe three URL checks, in that order
403caller does not own this merchantRecovered signer is not the on-chain owner
404merchant not found on-chainownerOf reverted
409merchant not registered — complete merchant registration before editing the profileOwned on-chain, no backend record
500internal errorThe 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

FieldTypeNotes
merchant_idintegerRequired. Negative is a 400
product_idstringRequired. 32-byte hex
endpoint_urlstringRequired. http or https, with a host
name, description, categorystringMetadata. 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"}'

Response201, 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

StatusBodyCause
400invalid requestUnparseable body, or negative merchant_id
400invalid product_id: must be 32-byte hexWrong length or not hex
400invalid endpoint_urlUnparseable, no host, or a scheme other than http/https
403caller does not own this merchantRecovered signer is not the on-chain owner
404merchant not found on-chainownerOf reverted after the retry window
404product not found on-chainThe product read reverted after the retry window
409merchant not registered — complete merchant registration before adding productsThe product's merchant has no backend record. Register the merchant first
500internal errorA 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

FieldTypeNotes
resourcestringRequired. 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"}'

Response200, always, when the probe ran.

FieldNotes
passtrue only when all three substantive checks passed
checksArray of { name, ok, detail }. detail is omitted when empty
CheckPasses when
reachableOnly ever reported on failure. Its detail carries the transport error, or the fixed string host not allowed
returns_402The status was 402. detail is status N either way
payto_is_splitrouterA 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_attributionThe 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

StatusBodyCause
400invalid requestUnparseable body
400invalid resource urlMissing host, or a scheme other than http/https
429rate limitedEither 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

NameTypeNotes
idintegerIn the path. Zero is valid here; negative is a 400
chain_idintegerOptional. 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"

Response200.

FieldNotes
conversionsPurchases attributed to this merchant
revenue_grossUSDC base units
escrow_holdingEscrows still holding
escrow_settledEscrows settled
product_countAll products, active and inactive
active_product_countThe 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

StatusBodyCause
400invalid merchant idUnparseable or negative
403caller does not own this merchantRecovered signer is not the on-chain owner
404merchant not foundownerOf reverted, or the merchant has no backend row
500internal errorThe 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.

Response200.

BodyMeaning
{ "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"
}

Errors400 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.json

Response200.

BodyMeaning
{ "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.

Errors400 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"

Response200, with kinds: one entry for the x402 rail, plus one for the charge rail when that rail is configured.

FieldNotes
x402Version2
schemeexact on both entries
networkThe 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"

Response200.

{ "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

FieldTypeNotes
txHashstringRequired. The charge transfer's 32-byte hash, 0x-prefixed or bare. Also the paymentRef
attributionTokenstringRequired. The base64url token, unpadded
buyerAgentIdintegerRequired, and 0 is valid. Absent is a 400 — the field is nullable precisely so an omitted id cannot be read as agent 0
buyerSignaturestringRequired. 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…"}'

Response200.

FieldNotes
paymentRefThe transaction hash, echoed
amountThe on-chain amount, as a decimal string
settledHardcoded false on the normal path. See below
storedfalse 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

StatusBodyCause
400invalid request bodyUnparseable JSON
400buyerAgentId requiredThe field was omitted
400buyerAgentId must not be negativeNegative
400invalid txHash: …Not a 32-byte hex hash
400invalid attributionToken encodingNot unpadded base64url
400attribution token verification failedSignature did not verify
400attribution token missing chain IDThe token's chain id was zero
400attribution token chain ID does not match this facilitatorWrong chain
400charge transfer not verifiedThe transfer is unconfirmed, or does not match. Chain detail stays in our logs
400invalid buyerSignature: …Not 65 bytes, or unrecoverable
400buyerSignature not signed by the transfer senderRecovered signer is not the payer
400buyerAgentId is not owned by the transfer senderownerOf does not match the payer
409charge cannot be attested: product is not active / merchant is not activeThe active-state gate refused
500internal errorThe owner lookup, the signing, or the insert failed
503active-state lookup failed, retryA transient chain read. Retry — nothing was persisted
503reviewer rank lookup failed, retryThe 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