List a product
The terms go on-chain, the copy comes to us
A listing is two writes. addProduct puts the terms on-chain — the commission rate the split
reads, the product type, and the active flag. One signed request attaches the four fields a buyer's
agent searches on: name, description, category and the endpoint it pays. What the product
record holds, and what product type changes downstream, are on Identity and
commitments. Nothing is deployed on a public chain yet — see
Quickstart.
What you will build
One product on-chain under your merchant id, created active with a commission rate, and the same product in our records with its name, description, category and purchase endpoint.
Before you start
- An active merchant identity, registered with us — Register your merchant identity. Both writes fail without it, and one of them fails after you have paid gas.
- A funded wallet, the identity owner's, for one transaction and for the signature on the API call.
cast, and acurlthat can sign a request — see The API for the signature construction. The snippets assume$TSand$SIG.
The metadata lives at api.opensouk.ai.
Prompt mode
Show the prompt
List a product for our merchant identity on the OpenSouk protocol on Base mainnet.
1. Resolve PRODUCT_REGISTRY from the ProtocolAddressRegistry under keccak256
("PRODUCT_REGISTRY"). Do not hardcode it.
2. Choose a productId: any bytes32, unique per merchant. Use keccak256 of a stable slug so
the id is reproducible from the slug.
3. Send addProduct(merchantId, productId, commissionBps, productType, productCardURI) from
the identity owner's wallet. commissionBps is basis points out of 10000. productType is
a uint8 enum: 0 is Repeat, 1 is OneOff, and it can never be changed afterwards. There is
no cashback argument and no active argument — the product is created active.
4. POST /v1/merchant/product signed, with merchant_id, product_id, endpoint_url, name,
description and category. endpoint_url must be an http or https URL with a host, and it
is the URL a buyer's agent will actually call to pay. The commission rate, the active
flag, the product type and the card URI are read from the chain by this route and are
NOT taken from the body.
5. Verify with an unsigned GET /v1/merchant/product?merchant_id=…&product_id=…
Do step 3 before step 4: the API refuses metadata for a product it cannot find on-chain.Manual mode
Resolve the product registry
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 PRODUCT_REGISTRY=$(cast call "$PAR" 'getAddress(bytes32)(address)' \
"$(cast keccak PRODUCT_REGISTRY)" --rpc-url "$RPC")Choose a product id
A productId is any bytes32, unique per merchant but not globally — two merchants may use the
same id for different products.
export PRODUCT_ID=$(cast keccak mainnet-rpc-10m-credits)keccak256 of a slug is a convention rather than a requirement: the id is reproducible from the
slug, so nothing has to remember a random 32-byte value. It is permanent — there is no rename, and
reusing the slug for a different product reverts.
Add the product on-chain
# 500 is commissionBps — 5% of a 10000 denominator. The uint8 after it is
# productType: 0 is the Repeat member, 1 is OneOff.
cast send "$PRODUCT_REGISTRY" 'addProduct(uint256,bytes32,uint16,uint8,string)' \
"$MERCHANT_ID" "$PRODUCT_ID" 500 0 'ipfs://bafy…' \
--rpc-url "$RPC" --private-key "$MERCHANT_KEY"Five things about that call:
- Your merchant must be active, not merely registered. The registry reads
isActiveand revertsMerchantNotActiveotherwise. This is the revert that costs gas to discover. - The product is created active. There is no active argument;
setProductActiveis how you close a listing later. productTypeis fixed at listing. No function changes it, so a product that should exist in both flavours is two listings with two ids.productCardURImay be empty, and it is not carried on the event — read it back withgetProductrather than from logs.- There is no cashback field. Cashback is pledged by reviewer agents out of their own commission — Set commission.
The caller may be the identity owner or an address holding PRODUCT_MANAGER_ROLE for this
merchant — the owner passes that check without holding the role.
Attach the metadata
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\":$MERCHANT_ID,\"product_id\":\"$PRODUCT_ID\",
\"endpoint_url\":\"https://rpc.example.com/v1/mainnet\",
\"name\":\"Mainnet RPC, 10M credits\",
\"description\":\"Archive-node RPC with a 50ms p95 target.\",
\"category\":\"RPC Provider\"}"201 returns the two ids and nothing else:
{ "merchant_id": 42, "product_id": "0x…" }- Only those four fields are yours to set here. Commission, active state, product type and the card URI are read from the chain by this route rather than taken from your body, so a body that disagrees with the chain changes nothing. A repeat call edits the four; it is not a conflict.
endpoint_urlis load-bearing. It is the URL a buyer's payment tooling calls, taken from the attribution token rather than from any page of yours, so it must be the paid endpoint itself and not a product page. It must parse with anhttporhttpsscheme and a host.categoryis matched exactly, as a string. Exactly one value has a review schema and a verification checklist of its own today —RPC Provider— and any other value, including a differently-cased or abbreviated one, gets the generic schema and no checklist. SorpcandRPC Providerare two different categories; Reviews is the rule.product_idis accepted with or without the0xprefix and must decode to exactly 32 bytes.
Verify
The read needs no signature.
curl -G https://api.opensouk.ai/v1/merchant/product \
--data-urlencode "merchant_id=$MERCHANT_ID" \
--data-urlencode "product_id=$PRODUCT_ID"{
"merchant_id": 42,
"product_id": "0x…",
"commission_bps": 500,
"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 Provider",
"created_at": "2026-06-14T09:12:03Z",
"product_type": "Repeat",
"product_rank": 0.5
}Three fields read differently than they look:
cashback_bpsis always0. A retired field kept for response compatibility, not your rate and not anybody's.product_typeis a string —RepeatorOneOff— not theuint8you passed.- An inactive product is a
404here, hidden rather than reported. So a404immediately after a successfuladdProductmeans our indexer has not caught up yet; retry rather than re-listing, because a secondaddProductwith the same id reverts.
If you are the first merchant on a fresh deployment, one asymmetry applies: identity ids start
at zero, and this read rejects any merchant_id at or below zero with 400 invalid merchant_id
while both registration routes accept zero. A merchant holding id 0 completes every step above
and then cannot use this check.
Errors and retries
On-chain:
| Revert | Cause |
|---|---|
MerchantNotActive(uint256) | Your merchant's isActive is false. Activate, then retry |
ProductAlreadyExists(uint256, bytes32) | That id is taken for this merchant. Choose another slug |
InvalidRates(uint16) | commissionBps above 10000 |
Unauthorised(uint256, bytes32, address) | The sender holds neither the product role nor the identity |
MerchantNotFound(uint256) | No such identity token on this deployment |
From the API:
| Status | Body | Cause |
|---|---|---|
400 | invalid request | Missing body, or a negative merchant_id |
400 | invalid product_id: must be 32-byte hex | Not 32 bytes once 0x is stripped |
400 | invalid endpoint_url | Unparseable, no host, or a scheme other than http/https |
403 | caller does not own this merchant | The recovered signer is not the on-chain owner |
404 | product not found on-chain | getProduct reverted after the retry window. The addProduct transaction has not confirmed, or the ids differ |
409 | merchant not registered — complete merchant registration before adding products | The merchant is active on-chain but has no record with us. Register the merchant, then retry — the product is already on-chain and does not need re-adding |
500 | internal error | A transient chain read or database write. Retry |
Search relevance arrives after the response. The route computes an embedding for the product's
name, description and category in the background, best-effort. A failure there is logged and
ignored, so a 201 does not guarantee the product is semantically searchable yet — and repeating
the metadata call recomputes it.
Next steps
- Accept agent payments over x402 — wiring the
endpoint_urlyou just registered - Merchant routes — both routes above, field by field
- Registries —
addProduct,setProductActiveandsetProductCardURI, signature by signature