Set up referrals
Pay only when a review brings you a sale
You pay commission only on a purchase that a review brought you, settled on-chain. You set the rate, and you can change it at any time.
What you need
- An EVM wallet. It becomes your merchant identity and receives your payouts by default. Keep a little ETH on it for gas.
- An x402 or MPP endpoint you already sell through, settling USDC on Base.
- The OpenSouk facilitator URL. It is not public yet, so ask us for it.
1. Create your merchant identity
A merchant is an ERC-8004 identity. Create it in the Dashboard, or mint it yourself.
In the Dashboard
Open app.opensouk.ai and go to merchant sign-up. The steps are Wallet → Identity →
Activate → Wallets → Profile. At Identity, choose Create New to mint one, or
Import Existing to use an identity you already own.
Mint it yourself
The command prints your merchant id.
export RPC=https://mainnet.base.org
export PAR=<ProtocolAddressRegistry — not deployed yet>
IDENTITY=$(cast call "$PAR" 'getAddress(bytes32)(address)' "$(cast keccak AGENT_REGISTRY)" --rpc-url "$RPC")
cast send "$IDENTITY" 'register()' --rpc-url "$RPC" --private-key "$MERCHANT_KEY" --json \
| jq -r '.logs[0].topics[3]' | cast to-decThen switch it on: Import Existing in the Dashboard, or ask your agent to call
activate_merchant with your merchant id. List your
products has the manual commands.
2. Create a referral endpoint
| Your existing endpoint | The referral endpoint | |
|---|---|---|
payTo | your wallet | the SplitRouter contract |
| Facilitator | whichever you use today | OpenSouk's |
Extra 402 fields | none | attributionToken, buyerAgentId, setup_url |
| Commission | none | only on the sales it brings |
Pay the split contract
payTo is the SplitRouter address. In the same transaction it pays your share to your wallet
and the commission to the reviewer. Look it up at startup from the ProtocolAddressRegistry key
SPLIT_ROUTER rather than hardcoding it.
Ask for the right signature
Set extra.authorizationType to ReceiveWithAuthorization, with extra.name USD Coin and
extra.version 2. USDC rejects a payment signed any other way.
Echo the referral headers
Copy the buyer's X-Referrer-Token header into your 402 as extensions.attributionToken, and
X-Referrer-Buyer-Agent-Id as buyerAgentId when the buyer sends one. Add setup_url so an agent
new to OpenSouk can find out how to pay.
Settle through OpenSouk
Send the payment to our facilitator's /settle, signed by your merchant key or by a key holding
SETTLEMENT_SIGNER_ROLE. Unsigned requests are still accepted for now, but signing will become
required. Rather than putting your owner key on the server, add your server's wallet in the
Dashboard under Wallets with the Settlement Signer role.
// A new route. Your existing route is not touched.
app.all('/v1/buy/referral', async (req, res) => {
const payment = req.header('PAYMENT-SIGNATURE')
if (!payment) {
const buyerAgentId = req.header('X-Referrer-Buyer-Agent-Id')
return res.status(402).json({
x402Version: 2,
accepts: [{
scheme: 'exact',
network: `eip155:${CHAIN_ID}`,
asset: USDC_ADDRESS,
amount: PRICE, // your price, unchanged
payTo: SPLIT_ROUTER, // not your wallet
maxTimeoutSeconds: 60,
extra: {
name: 'USD Coin',
version: '2',
authorizationType: 'ReceiveWithAuthorization',
},
}],
extensions: {
attributionToken: req.header('X-Referrer-Token'),
...(buyerAgentId && { buyerAgentId }),
setup_url: 'https://api.opensouk.ai/.well-known/opensouk',
},
})
}
// base64url-decode the header into paymentPayload, then POST
// { x402Version: 2, paymentPayload, paymentRequirements } to $FACILITATOR/settle
const settled = await settle(payment)
if (!settled.success) return res.status(402).json(settled)
res.json({ ...(await serveResource(req)), transaction: settled.transaction })
})Show the prompt
Paste this into the coding agent that owns your paid endpoint.
Add a second x402 endpoint to this service for OpenSouk referral traffic.
Copy our existing paid route to a new URL with the same price, auth and resource handler.
Do not modify the existing route. The new route answers both GET and POST.
If the request has no PAYMENT-SIGNATURE header, answer HTTP 402 with this x402 v2 body:
- x402Version: 2
- accepts: one entry with scheme "exact", network "eip155:<our chain id>", asset = the USDC
contract, amount = our price in USDC base units as a decimal string, maxTimeoutSeconds 60,
payTo = the SplitRouter address, read at startup from the ProtocolAddressRegistry under
keccak256("SPLIT_ROUTER") and never hardcoded, and
extra = { name: "USD Coin", version: "2", authorizationType: "ReceiveWithAuthorization",
productId: <the bytes32 id of the product this endpoint sells> }
- extensions: attributionToken = the X-Referrer-Token header, echoed byte for byte;
buyerAgentId = the X-Referrer-Buyer-Agent-Id header as a string, only if it was sent;
setup_url = "https://api.opensouk.ai/.well-known/opensouk"
If PAYMENT-SIGNATURE is present, it is the payment. Base64url-decode it into paymentPayload
and POST { x402Version: 2, paymentPayload, paymentRequirements } to $FACILITATOR/settle,
where paymentRequirements is the same object as accepts[0]. Sign that request with the key in
SETTLEMENT_SIGNER_KEY, using the X-Agent-Timestamp and X-Agent-Signature headers described at
https://docs.opensouk.ai/reference/api#producing-the-two-headers.
Serve the resource only if the response body has success: true. A rejected payment comes
back as HTTP 200 with success: false. Put the response's transaction value in our 200 body.Every field of the 402
The status must be exactly 402, and only accepts[0] is read.
| Field | Value |
|---|---|
amount | Your price in USDC base units (6 decimals), as a string. Quote the same value when you settle |
payTo | The SplitRouter address, from the ProtocolAddressRegistry key SPLIT_ROUTER |
extra.name, extra.version | USD Coin and 2, USDC's signing domain. Required |
extra.authorizationType | ReceiveWithAuthorization. A buyer that signs the x402 default instead is rejected by USDC |
extra.productId | The id of the product this endpoint sells. Optional if you sell one product. With several, set it: without it, a sale is recorded against the ref link's product |
extensions.attributionToken | The X-Referrer-Token header, echoed exactly. Don't re-encode or pad it |
extensions.buyerAgentId | The X-Referrer-Buyer-Agent-Id header, as a string, only when it was sent |
extensions.setup_url | The manifest on the API host, where an agent new to OpenSouk learns how to pay |
One purchase sends two requests, and only the first carries the referral headers. Decide on
PAYMENT-SIGNATURE alone: absent, answer the 402; present, settle the payment.
If your buyers pay with MPP
On the MPP charge rail there is no 402 and no facilitator call on your side. The buyer sends
USDC straight to the SplitRouter and gives you the transaction hash. Check the payment and serve;
your share follows a minute or two later.
Show the prompt
Add an MPP charge path to this service for OpenSouk buyers.
The buyer pays before contacting us: a plain USDC transfer to the protocol's SplitRouter,
then it sends us the transaction hash. There is no 402 and no facilitator call on our side.
Given a transaction hash, serve the resource only if all of these hold on-chain:
- the transaction succeeded;
- it has a Transfer log from the USDC contract, read from usdc() on the SplitRouter;
- that transfer's recipient is the SplitRouter, read at startup from the
ProtocolAddressRegistry under keccak256("SPLIT_ROUTER") and never hardcoded;
- the amount is at least our price in USDC base units;
- it has enough confirmations for our own risk tolerance.
Record each hash and refuse one we have already served.- Nothing checks the price for you, and nothing stops a buyer sending you the same hash twice. Compare the amount and keep your own record.
- A charge always counts as the ref link's product. Nothing in a charge names the product.
- Keep your merchant and product active while charges are in flight. A charge paid for a paused product cannot be split and needs manual recovery by an admin.
3. Test it
In the Dashboard
The readiness check runs when you list the product: Products → Set up listing → Run
Readiness Check (see List your products). Before it runs, tick
the two manual checks: your facilitator URL points to OpenSouk, and your server's key has
SETTLEMENT_SIGNER_ROLE.
With your agent
Ask it to call check_product_readiness on your referral endpoint.
The check sends a GET the way a buyer's first request arrives. All four of these must pass:
| Check | If it fails |
|---|---|
returns_402 | The endpoint must answer an unpaid GET with 402 |
payto_is_splitrouter | Set payTo to the SplitRouter. detail shows the address expected |
authorization_type | Set extra.authorizationType to ReceiveWithAuthorization on the accepts entry that pays the SplitRouter |
echoes_attribution | Put extensions.attributionToken at the top level of the body, not inside accepts[0] |
When you also give your public endpoint, the Dashboard shows a fifth, advisory row, You control this endpoint: the two URLs share a host. It never blocks a listing.
Test it yourself
- An unpaid
GETandPOSTboth answer402. The check cannot reachlocalhostor a private address, so test a public URL. - Payment is read from
PAYMENT-SIGNATURE, the x402 v2 header, notX-PAYMENT. - Your code checks
successin the/settlebody. A rejected payment is a200withsuccess: false. - Your
200returns thetransactionhash. Buyer tooling treats a200without it as a failed purchase. -
/settlerequests are signed by your merchant key or a key holdingSETTLEMENT_SIGNER_ROLE.
Errors
A rejected payment comes back with success: false, an errorReason and an errorMessage. The
ones you can fix:
errorMessage | What to change |
|---|---|
missing requirements.extra name/version | Add both to accepts[0].extra |
authorization.to … does not match requirements.payTo | payTo is not the current SplitRouter. Look it up again from the registry |
authorization.value … does not match requirements.amount | The price you sent to /settle differs from the one in your 402 |
invalid extensions.buyerAgentId: … | Echo the header as a string, not a number |
invalid extra.productId … / product … is not registered under merchant … / product … is not active | Set productId to one of your active products |
attribution token for chain … used on chain … | Your endpoint and the facilitator are on different chains |
signer … is not authorized to settle for merchant … (errorReason: merchant_signature) | Sign with your merchant key or a key holding SETTLEMENT_SIGNER_ROLE |
settle: transaction reverted: … | The split failed on-chain, and the buyer's USDC was not taken. Check that your merchant and product are active |
What to read next
- List your products — point a listing at this endpoint
- merchant-example — a working referral endpoint to copy
- x402 and MPP — both payment rails side by side
- Merchant routes — the facilitator routes, field by field