Register your merchant identity
Four calls, in the one order that works
A merchant is an identity token you hold, plus two records: an on-chain active flag, and a row in our database. Four calls create all of it — two transactions and two signed requests — and each one reads state the previous one wrote, so the order is not a preference.
What a merchant identity is, and which key may act for it, is on Identity and commitments. Nothing is deployed on a public chain yet — see Quickstart.
What you will build
An ERC-8004 identity token held by your wallet, whose id is your merchant_id and whose wallet is
where the split contract sends your net; that merchant active on-chain; and an agent record and a
merchant record with us.
Before you start
- A funded wallet. It pays gas for two transactions, it becomes the identity's owner, and its key signs every API call below.
cast, from Foundry.- A
curlthat can sign a request. Every write is a signed request:X-Agent-Signatureoverkeccak256(body ‖ uint64_be(unixSeconds)), with the same seconds inX-Agent-Timestamp. The construction, the ±60-second window and the replay guard are on The API. The snippets assume you have$TSand$SIG.
Both records live at api.opensouk.ai. An identity id is per deployment: the same number on
two networks is two different merchants.
Prompt mode
Show the prompt
Register us as a merchant on the OpenSouk protocol on Base mainnet, in this order. Stop and
report if any step fails rather than continuing.
1. Resolve MERCHANT_REGISTRY and AGENT_REGISTRY from the ProtocolAddressRegistry, using
keccak256 of those exact key names. Do not hardcode addresses.
2. Send register() on AGENT_REGISTRY from our wallet. Read the minted token id out of the
receipt — ids are assigned from a counter that starts at zero, so id 0 is valid and must
not be treated as "no id". That id is our merchant_id, and the sending wallet is written
as the identity's payout wallet by the mint itself.
3. Send setActive(merchant_id, true) on MERCHANT_REGISTRY from the SAME wallet. Only the
token's owner may call it.
4. POST /v1/agent/register with {"agent_id": <id>} and then POST /v1/merchant/register with
{"merchant_id": <id>}, both signed. Sign each call over its own exact request body — the
two cannot share a signature. Expect 201 from each; a 409 means the record already
exists, which on a retry is success.
Do step 3 before step 4: /v1/merchant/register reads isActive on-chain and refuses a
merchant that is not active.Manual mode
Resolve the two registries
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>
for KEY in AGENT_REGISTRY MERCHANT_REGISTRY; do
printf 'export %s=%s\n' "$KEY" \
"$(cast call "$PAR" 'getAddress(bytes32)(address)' "$(cast keccak "$KEY")" --rpc-url "$RPC")"
doneAGENT_REGISTRY is the ERC-8004 identity registry, which the protocol does not deploy. Your
merchant id is a token in it, and the same registry holds reviewer and buyer identities.
Mint the identity
cast send "$AGENT_REGISTRY" 'register()' \
--rpc-url "$RPC" --private-key "$MERCHANT_KEY"Read the minted id out of the receipt's Registered event and keep it:
export MERCHANT_ID=<the token id>.
Three things this call decides:
- The sending wallet becomes the payout wallet. The mint writes the sender as the identity's
agentWallet, and that is the address the split contract pays your merchant net to. It resolvesagentWalletfirst and falls back toownerOfonly when no wallet is set — see Identity and commitments for the rebinding rules and what a transfer of the token does. - Ids start at zero. The registry assigns from a counter beginning at
0, so the first identity on a fresh deployment hasmerchant_id0. That is a real id, and a zero-means-missing check anywhere in your own code will lose it. - There are three
registeroverloads. The bare one is enough; the others take a metadata URI and metadata entries, and none of them changes anything the protocol reads.
Activate the merchant
cast send "$MERCHANT_REGISTRY" 'setActive(uint256,bool)' "$MERCHANT_ID" true \
--rpc-url "$RPC" --private-key "$MERCHANT_KEY"Only the token's owner may call this — not a role holder, not an admin. isActive then reads true
only while your own flag is set and the protocol has not suspended you. Both purchase rails
check it before a split runs, and listing a product requires it, so nothing works before this
transaction lands.
Register the identity, then the merchant
Two signed calls, in this order.
curl -X POST https://api.opensouk.ai/v1/agent/register \
-H 'Content-Type: application/json' \
-H "X-Agent-Timestamp: $TS" -H "X-Agent-Signature: $SIG" \
-d "{\"agent_id\":$MERCHANT_ID}"
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\":$MERCHANT_ID}"Each returns 201 with the id and the owner address, read from the chain rather than from your
body:
{ "merchant_id": 42, "owner_address": "0x…" }Sign each call over its own body. The signature covers the exact bytes you send, so the two
requests cannot share a $SIG, and re-serialising the JSON between signing and sending
invalidates it.
Why the order. /v1/merchant/register reads ownerOf and isActive on-chain before it will
accept you; /v1/agent/register reads ownerOf and, non-fatally, the identity's wallet and URI.
Both re-read for a bounded window, so calling them seconds after your transactions confirmed is
expected rather than racy.
Optional: publish your profile copy
One idempotent call sets a description and a landing page, both public. The same call edits both later.
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\":$MERCHANT_ID,
\"description\":\"Low-latency Ethereum RPC.\",
\"landing_page_url\":\"https://rpc.example.com\"}"description is capped at 2000 characters and landing_page_url at 2048, and the URL must parse
with an http or https scheme and a host. Neither field is ever fetched by us. This route is one
place a merchant holding id 0 cannot go: it requires a merchant_id above zero, while the two
registration routes accept zero.
Optional: delegate without handing over the key
The identity owner passes every per-merchant role check already, so you only need roles to let a different address act.
cast send "$MERCHANT_REGISTRY" 'grantRole(uint256,bytes32,address)' \
"$MERCHANT_ID" "$(cast keccak PRODUCT_MANAGER_ROLE)" "$OPS_ADDRESS" \
--rpc-url "$RPC" --private-key "$MERCHANT_KEY"PRODUCT_MANAGER_ROLE covers adding products, editing the metadata URI and toggling the active
flag; COMMISSION_MANAGER_ROLE covers the rate and nothing else, so an address that edits a
catalogue cannot move money. Granting a role does not delegate our API: every signed request is
checked against ownerOf alone, so a role holder cannot register products with us.
Verify
Two reads, neither of which needs a signature.
cast call "$MERCHANT_REGISTRY" 'isActive(uint256)(bool)' "$MERCHANT_ID" --rpc-url "$RPC"
curl -G "https://api.opensouk.ai/v1/merchant/$MERCHANT_ID"A 200 carrying your owner_address, active: true and an empty product_ids is a complete
registration with nothing listed yet.
An inactive or suspended merchant is a 404 here, not a 403. The detail route hides both
rather than reporting them, so a 404 on a merchant you just registered means the active flag did
not land — read isActive before suspecting the registration. This read rejects merchant_id zero
with a 400, the same asymmetry the profile route has.
Errors and retries
| Status | Body | Cause |
|---|---|---|
400 | invalid merchant_id / invalid agent_id | Missing, unparseable, or negative |
403 | caller does not own this merchant | The recovered signer is not the on-chain owner. Sign with the identity owner's key |
403 | merchant not active on-chain | setActive has not landed, or you are suspended. Activate first |
404 | merchant not found on-chain / agent not found on-chain | ownerOf reverted for that id after the retry window. Wrong id, wrong network, or the mint has not confirmed |
409 | merchant already registered / agent already registered | The record exists. On a retry this is the outcome you wanted |
500 | internal error | A transient chain read or database write. Retry; a failed read is never reported as "not active" |
On-chain, three reverts are worth recognising:
| Revert | Cause |
|---|---|
NotMerchantOwner(uint256, address) | setActive sent from an address that does not hold the token |
MerchantIsSuspended(uint256) | Activating a merchant the protocol has suspended. Clearing it is not yours to do |
MerchantNotFound(uint256) | ownerOf reverted — no such token on this deployment |
Next steps
- List a product — the next four fields, and the first one that costs gas
- Accept agent payments over x402 — wiring the endpoint agents will pay
- Merchant routes — every route above, field by field