Store and Rewards
Sell items in your game or app for your own token, give items away as rewards, and unlock content for the people who hold your token. Buyers pay you directly on chain; Chain Daddy keeps the catalog, the item ledger and a signed receipt for every purchase.
There are three ways to sell:
- Your own store, in your game. Your game shows its own catalog in its own UI and talks to the store API from its server with a store key. This is the main path, and the rest of this page is built around it.
- An app on your token page. Your app asks the page to run the purchase with one action; the page handles the wallet. See Selling from an app on your token page.
- The Item showcase on your token page. A ready-made widget for the same flow, for creators without a game. See The Item showcase.
Every action is an API call, a chaindaddy iap … command and an MCP tool, and every write can be retried safely.
Sell from your own game
0. Set up once
npm install -g @chaindaddy/cli@beta
export CHAINDADDY_API_KEY=cd_live_… # your account key (see Authentication)
# Open the store for your token page (chain key + registration id)
chaindaddy iap store open --chain base --crown 8 --json
# → {"ok":true,"idempotencyKey":"…","store":{"id":"3f0c…","feeBps":0,"live":true,…}}
# Add an item and put it on sale (see Items below for the JSON)
chaindaddy iap sku create 3f0c… --file car-red.json
chaindaddy iap sku update 3f0c… car.red --status active
# A key for your game server: create orders, read and spend items
chaindaddy iap key create 3f0c… --name game-server \
--scope orders:read orders:write entitlements:read entitlements:write
# prints the cd_iap_… secret ONCE: put it in your server's secrets1. Your server creates the order
When a player taps Buy, your server asks for an order for that player's wallet. appAccountToken is your own id for the player; it comes back on the order, the receipt and every event, so you can match a payment to an account.
curl -X POST https://api.chaindaddy.io/api/v2/iap/orders \
-H "Authorization: Bearer $CHAINDADDY_IAP_KEY" \
-H "Idempotency-Key: cart-5521" \
-H "Content-Type: application/json" \
-d '{"storeId":"3f0c…","sku":"car.red","quantity":1,"wallet":"0xabc…","appAccountToken":"player-42"}'Same thing from the CLI: chaindaddy iap order create 3f0c… --sku car.red --wallet 0xabc… --app-account-token player-42. MCP: iap_create_order.
The order locks the price, the payees and a deadline about 20 minutes out (5 minutes for an item priced in dollars and paid in your token or the chain's coin), and says how to pay:
{
"id": "0x9e1f…",
"storeId": "3f0c…",
"sku": "car.red",
"quantity": 1,
"wallet": "0xabc…",
"appAccountToken": "player-42",
"currency": "0xToken…",
"currencyKind": "token",
"currencySymbol": "FLUXGP",
"currencyDecimals": 18,
"amount": "5000000000000000000000",
"status": "pending",
"expiresAt": "2026-10-02T18:24:11Z",
"payment": {
"kind": "evm",
"chainId": "eip155:8453",
"checkout": "0xCheckout…",
"order": {
"orderId": "0x9e1f…",
"token": "0xToken…",
"payees": ["0xPayout…"],
"amounts": ["5000000000000000000000"],
"deadline": 1791051851
},
"total": "5000000000000000000000",
"permit": { "supported": true, "name": "FLUX GP", "version": "1" },
"permitTypedData": { "domain": { "…": "…" }, "types": { "…": "…" }, "primaryType": "Permit", "message": { "…": "…" } },
"calls": {
"approve": { "to": "0xToken…", "data": "0x095ea7b3…", "value": "0" },
"pay": { "to": "0xCheckout…", "data": "0x…", "value": "0" }
}
}
}Pass payment to the game client. Use one Idempotency-Key per purchase attempt (your cart id, say): a retry returns the same order instead of a second one.
2. The player pays from their wallet
The client has two ways to pay, and never needs to know an amount:
calls: raw transactions to send in order:approve, the ERC-20 approval, thenpay, the checkout payment. When the item is paid in the chain's coin there is noapprove, andpay.valueis the total: the coin travels with the payment.permitTypedData: present only when the currency supports permits (permit.supported; never for the chain's coin). The player signs it, and one transaction callspayWithPermiton the checkout. One signature and one transaction, no separate approval.
With viem:
import { createPublicClient, createWalletClient, custom, http, parseAbi, parseSignature } from 'viem';
import { base } from 'viem/chains';
const checkoutAbi = parseAbi([
'function payWithPermit((bytes32 orderId,address token,address[] payees,uint256[] amounts,uint256 deadline) o, uint256 permitDeadline, uint8 v, bytes32 r, bytes32 s)',
]);
const publicClient = createPublicClient({ chain: base, transport: http() });
const wallet = createWalletClient({ chain: base, transport: custom(window.ethereum) });
const [account] = await wallet.requestAddresses();
async function pay(payment): Promise<`0x${string}`> {
if (payment.permitTypedData) {
const td = payment.permitTypedData;
const sig = await wallet.signTypedData({
account, domain: td.domain, types: td.types, primaryType: td.primaryType, message: td.message,
});
const { r, s, v, yParity } = parseSignature(sig);
const o = payment.order;
return wallet.writeContract({
account,
address: payment.checkout,
abi: checkoutAbi,
functionName: 'payWithPermit',
args: [
{ orderId: o.orderId, token: o.token, payees: o.payees, amounts: o.amounts.map(BigInt), deadline: BigInt(o.deadline) },
BigInt(td.message.deadline),
Number(v ?? BigInt(yParity + 27)),
r,
s,
],
});
}
const { approve, pay: checkoutPay } = payment.calls;
if (approve) { // absent when the item is paid in the chain's coin
const hash = await wallet.sendTransaction({ account, to: approve.to, data: approve.data });
await publicClient.waitForTransactionReceipt({ hash }); // the approval must land before the payment
}
// value is the total when paying in the chain's coin, 0 otherwise
return wallet.sendTransaction({ account, to: checkoutPay.to, data: checkoutPay.data, value: BigInt(checkoutPay.value) });
}Solana. payment is {"kind":"solana", "transaction", "reference", "memo", …}: transaction is a base64 unsigned transaction, built by the API, that pays you (and the fee, if any) directly, in the token, SOL or USDC the item is paid in. The player's wallet signs and sends it; the resulting signature takes the place of the transaction hash below.
3. Hand the transaction to your server
The client gives the hash to your server, which submits it:
curl -X POST https://api.chaindaddy.io/api/v2/iap/orders/0x9e1f…/submit \
-H "Content-Type: application/json" \
-H "Idempotency-Key: cart-5521-submit" \
-d '{"txHash":"0x5b7c…"}' # Solana: {"signature":"…"}Submitting is optional. Chain Daddy watches the checkout on every chain and moves the order to paid within a couple of seconds of the payment landing on chain, whether or not anyone submits it, so an order still settles if your client crashes after paying. Submit when you already have the hash: it checks that one transaction immediately.
4. Wait for confirmed
Poll the order with the store key until it is confirmed, or subscribe to the iap.order.confirmed webhook:
curl https://api.chaindaddy.io/api/v2/iap/orders/0x9e1f… \
-H "Authorization: Bearer $CHAINDADDY_IAP_KEY"
# → {"status":"confirmed","confirmations":5,"requiredConfirmations":5,"receipt":"eyJhbGciOiJFZERTQSIs…",…}paid means the payment is on chain and gathering confirmations; do not hand over the item yet. Polling also moves it along: each read of a paid order re-checks its payment on chain, so an order you poll every second or two reads confirmed as soon as it is deep enough. See How a purchase settles for how long that is on each chain.
5. Verify the receipt
A confirmed order carries a signed receipt. Check it offline against the public keys, or online; see Verifying receipts.
6. Read and spend items on your server
The item ledger is the source of truth for what a player owns:
chaindaddy iap entitlements 3f0c… --wallet 0xabc… --json # GET …/entitlements?wallet=
chaindaddy iap consume 3f0c… --wallet 0xabc… --sku boost --qty 1 --idempotency-key race-7731-boost-2Tools for agents
The MCP server exposes the same calls as iap_* tools. It reads your account key from CHAINDADDY_API_KEY and a store key from CHAINDADDY_IAP_KEY:
{
"mcpServers": {
"chaindaddy": {
"command": "npx",
"args": ["@chaindaddy/mcp"],
"env": {
"CHAINDADDY_API_KEY": "cd_live_…",
"CHAINDADDY_IAP_KEY": "cd_iap_…"
}
}
}
}| MCP tool | CLI | HTTP |
|---|---|---|
iap_open_store | iap store open | POST /api/v2/iap/stores |
iap_list_stores | iap store list | GET /api/v2/iap/stores |
iap_list_skus | iap sku list | GET /api/v2/iap/stores/{id}/skus |
iap_create_sku | iap sku create | POST /api/v2/iap/stores/{id}/skus |
iap_update_sku | iap sku update / archive | PATCH / DELETE /api/v2/iap/stores/{id}/skus/{sku} |
iap_create_order | iap order create | POST /api/v2/iap/orders |
iap_list_orders | iap order list | GET /api/v2/iap/stores/{id}/orders |
iap_get_order | iap order show | GET /api/v2/iap/stores/{id}/orders/{orderId} |
iap_refund_order | iap order refund | POST /api/v2/iap/stores/{id}/orders/{orderId}/refund |
iap_get_entitlements | iap entitlements | GET /api/v2/iap/stores/{id}/entitlements |
iap_grant / iap_consume / iap_revoke | iap grant / consume / revoke | POST /api/v2/iap/stores/{id}/{grants,consume,revoke} |
iap_create_drop | iap drop create | POST /api/v2/iap/stores/{id}/drops |
iap_set_drop_status | iap drop status | POST /api/v2/iap/stores/{id}/drops/{dropId}/status |
iap_add_drop_recipients | iap drop recipients | POST /api/v2/iap/stores/{id}/drops/{dropId}/recipients |
iap_award | iap drop award | POST /api/v2/iap/stores/{id}/drops/{dropId}/award |
iap_create_store_key | iap key create | POST /api/v2/iap/stores/{id}/keys |
iap_list_events | iap events | GET /api/v2/iap/stores/{id}/events |
The full request and response schemas are in the OpenAPI spec under the Creator Store tag.
Who can open a store
A store needs a Developer plan, or membership in the Developer Beta Program in good standing. Without one, every creator and server route answers 403 with code IAP_PLAN_REQUIRED, and the token page's Rewards tab shows what it would unlock. GET /api/v2/iap/eligibility tells a signed-in wallet where it stands.
You open a store as a wallet that manages the token page; changing the payout address takes the owner. The store stays live while the wallet that opened it keeps its plan. If that lapses, the store stops taking new orders and store keys stop working, but buyers can always read the items and receipts they already have.
Where you manage it
| What | Where |
|---|---|
| Items, orders, players, store keys, events, settings and purchase notifications | Developer portal → Store: chaindaddy.io/_developer/store. Pick a store, then /_developer/store/<storeId>/skus (or orders, players, keys, events, settings) |
| Rewards: claim links, pushes and game-awarded drops | Your token's manager: Community → Rewards, at https://chaindaddy.io/_/<SYMBOL>/rewards |
| Showing items on your token page | Add the Item showcase widget in the token page editor |
| Everything, from an agent or a game server | The API below, chaindaddy iap …, or the iap_* MCP tools |
A Developer-plan customer reaches the Store section without joining the developer program; the program's agreement is only needed to publish apps.
Authentication
Store routes take one credential, in Authorization: Bearer …. Unlike the rest of the API, they do not also need an X-API-Key header:
| Credential | Use it for |
|---|---|
Your account: a cd_live_ API key or a wallet session | Creator and admin work, including opening stores and managing store keys |
A store key, cd_iap_… | Your game server. Works on one store, limited to the scopes you gave it. Cannot open stores, list your stores or manage keys. Send only this key |
| None | Public reads (a store's catalog, a drop), GET /api/v2/iap/jwks, receipt verification and …/orders/{id}/submit |
The CLI reads the store key from --key or CHAINDADDY_IAP_KEY; the MCP server from CHAINDADDY_IAP_KEY.
Open a store
chaindaddy iap store open --chain base --crown 8curl -X POST https://api.chaindaddy.io/api/v2/iap/stores \
-H "Authorization: Bearer $CHAINDADDY_API_KEY" \
-H "Idempotency-Key: $(uuidgen)" -H "Content-Type: application/json" \
-d '{"chainKey":"base","crownId":8}'One store per token page, so opening it again returns the same store. Items are paid in the token itself unless you choose otherwise for an item (What the buyer pays in). Sales pay out to payoutAddress, which starts as the owner's wallet:
chaindaddy iap store update 3f0c… --payout 0xYourTreasury # owner only
chaindaddy iap store update 3f0c… --pause # stop selling; --resume to restartEVM chains and Solana are both supported. GET /api/v2/iap/config lists the chains the store runs on, how many confirmations each needs, and the currencies besides your token that each chain offers.
Items (SKUs)
Each item has a sku key, unique in your store: lowercase letters, digits, ., _ and -, up to 64 characters, starting with a letter or digit. Prices are base-unit strings of the item's currency, which is your token unless you choose another (for an 18-decimal token, 5000 tokens is "5000000000000000000000"). The CLI's --price-tokens 5000 does that conversion for you. We recommend pricing in dollars and taking payment in your token: a steady price for the buyer, and every sale is demand for your token.
| Type | A purchase gives | Good for |
|---|---|---|
consumable | quantity added to a balance; your game spends it with consume | Coins, lives, boosts |
non_consumable | Owned, at most one per wallet | Skins, cars, tracks |
timed | Access until an expiry, extended by durationSeconds × quantity from now or from the current expiry, whichever is later | Season passes. Renewing is another purchase; nothing charges a wallet automatically |
unlock | Never sold. Held for as long as its rule holds, checked live | Holder perks |
New SKUs start as draft. Set status to active to list them, and archive them to take them down (chaindaddy iap sku archive <store> <sku>); archiving never takes an item away from anyone who has it.
The examples below are from FLUX GP, a racing game.
A car skin (non_consumable)
{
"sku": "car.red",
"type": "non_consumable",
"name": "Red Racer",
"description": "Gloss red livery for your starter car.",
"imageUrl": "https://fluxgp.example/items/car-red.png",
"price": "5000000000000000000000",
"metadata": { "carId": "starter", "livery": "red" },
"status": "active"
}metadata is yours: any JSON your game needs to render or apply the item. It comes back on every SKU read.
A boost pack (consumable bundle)
A bundle grants other items instead of itself. Here, buying one boost.pack10 adds 10 to the player's boost balance. boost has no price, so it cannot be bought on its own; it is reward-only.
[
{ "sku": "boost", "type": "consumable", "name": "Nitro boost", "status": "active" },
{
"sku": "boost.pack10",
"type": "consumable",
"name": "Boost pack ×10",
"price": "1000000000000000000000",
"grants": [{ "sku": "boost", "quantity": 10 }],
"status": "active"
}
]When a player fires a boost, the game server spends one:
chaindaddy iap consume 3f0c… --wallet 0xabc… --sku boost --qty 1 --idempotency-key race-7731-boost-2If the balance is too low the call answers 409 INSUFFICIENT_QUANTITY and nothing changes.
A season pass (timed)
{
"sku": "pass.s1",
"type": "timed",
"name": "Season 1 pass",
"price": "20000000000000000000000",
"durationSeconds": 7776000,
"saleStartsAt": "2026-10-01T00:00:00Z",
"saleEndsAt": "2026-12-31T00:00:00Z",
"status": "active"
}90 days per purchase. A player who buys twice gets 180 days.
A holders-only track (unlock)
Held by any wallet with at least 1,000 tokens, for as long as it keeps them. Nobody buys it; it appears in that wallet's items with "source": "unlock".
{
"sku": "track.neon",
"type": "unlock",
"name": "Neon Circuit",
"rule": { "hold": { "min": "1000000000000000000000" } },
"status": "active"
}Price in dollars
Your token's price moves; the price of a coin pack in your game probably should not. Set priceUsdMicros instead of price and the item always costs about the same in dollars, whatever the token trades at. The unit is a millionth of a dollar, so $5 is 5000000.
This is the setup we recommend: priced in dollars, paid in your token. The buyer sees a steady price, and every purchase is someone getting and spending your token. It needs nothing extra: an item with no currency is paid in your token.
{
"sku": "coins.500",
"type": "consumable",
"name": "500 coins",
"priceUsdMicros": 5000000,
"minPrice": "1000000000000000000000",
"maxPrice": "500000000000000000000000",
"status": "active"
}From the CLI: chaindaddy iap sku create 3f0c… --sku coins.500 --type consumable --name "500 coins" --price-usd 5 --min-price-tokens 1000 --max-price-tokens 500000 --status active. MCP: priceUsdMicros, minPrice and maxPrice on iap_create_sku / iap_update_sku.
An item has either a fixed price (price) or a dollar price (priceUsdMicros), never both. When you update an item, setting one clears the other.
What the buyer pays. Nothing on chain knows about dollars. Each time an order is created, we convert the dollar price to an exact amount of the item's currency at that moment and lock it on the order, the same way a fixed price is locked:
- The rate is live. We read your token's price, and the chain's coin's, from DexScreener every 15 seconds, and a quote uses the average of the last 2 minutes of readings. An average means one odd trade cannot move what a buyer pays; a short window means a fast-moving token is still quoted at what it trades at now.
- The amount is rounded up to 4 significant figures, so a wallet shows a clean number like 140,100 rather than 140,056.27. That is at most about 0.1% above the exact conversion.
minPriceandmaxPrice(optional, base units per item) cap the conversion. Use them so a crash in your token's price cannot make an item cost a fortune in tokens, and a spike cannot make it almost free.- The platform fee (see The fee) is taken from the converted amount, exactly as from a fixed price.
- An item paid in USDC needs no rate at all: $5 is exactly 5 USDC.
The order's amount is what the buyer sees in the wallet and approves: an exact amount, fixed for the order's life. An order converted to your token or the chain's coin can be paid for 5 minutes, long enough for a wallet round trip and short enough that nobody holds a quote through a big move; other orders get about 20. If the price moves after that, the next order gets a new amount; an order already created never changes. A dollar-priced order also carries priceUsdMicros and usdPerToken, the dollar price of one whole unit of the currency that the quote used ("1" for USDC), and so does its receipt.
When there is no price. We would rather not sell than sell at a wrong price. Creating an order for a dollar-priced item paid in your token or the chain's coin answers 503 PRICE_UNAVAILABLE when:
- the newest reading is more than 45 seconds old (the price feed has stopped),
- there are fewer than 3 readings in the last 2 minutes (a new store's price takes about 30 seconds to warm up),
- the newest reading is more than 15% away from the 2-minute average (the price is moving too fast to quote fairly), or
- DexScreener has no price for the token at all (it follows no pool that trades it).
This usually clears within a minute. Tell the player to try again shortly. A fixed price, and a dollar price paid in USDC, never depend on any of this. On test networks, which no market prices, the rate comes from our own price records instead.
Your store's price field (on GET /api/v2/iap/stores/{id} and on the public store) shows what a dollar-priced item paid in your token would convert at right now: {"usdPerToken": "0.0000357", "source": "feed", "asOf": "…"}, or {"unavailable": "the price is moving too fast"} when an order would be refused. chaindaddy iap store show prints it.
In the public catalog (GET /api/v2/iap/public/stores/…) a dollar-priced item carries "priceIsEstimate": true and a price that is an estimate for one item at the current rate, for display ("$5.00 ≈ 140,100 FLUXGP"). It is null when no price is available right now. An item paid in USDC is exact, so its price is not an estimate. The locked amount on the order is the one that counts.
What the buyer pays in
Items are paid in your token by default, and we recommend keeping it that way: the point of a token page is that people buy your token. When an item suits another currency better, set its currency:
currency | The buyer pays in | price, minPrice, maxPrice are |
|---|---|---|
token (default) | Your token | Base units of your token |
native | The chain's coin: ETH, BNB, POL, SOL… | Base units of the coin (wei, lamports) |
usdc | The chain's dollar stable: USDC (USDG on Robinhood Chain; Circle USDC on Solana) | Base units of the stable (check its decimals: BNB Chain's is 18) |
GET /api/v2/iap/config lists each chain's currencies, each with its address, symbol and decimals. A chain without a dollar stable (Gnosis) refuses usdc.
{
"sku": "coins.500",
"type": "consumable",
"name": "500 coins",
"currency": "usdc",
"priceUsdMicros": 5000000,
"status": "active"
}From the CLI: chaindaddy iap sku create 3f0c… --sku coins.500 --type consumable --name "500 coins" --currency usdc --price-usd 5 --status active. MCP: currency on iap_create_sku / iap_update_sku.
- A dollar price in USDC is exact: no rate, no rounding, no
PRICE_UNAVAILABLE. It takes nominPriceormaxPrice. - A dollar price in the chain's coin converts at the coin's live price, with the same guards as your token.
- Changing an item's currency changes what its base units mean. An update that changes
currencyon an item with a fixed price must setprice(orpriceUsdMicros) in the same call, and it clearsminPriceandmaxPrice. A dollar price means the same in any currency and carries over. - An
unlockis never sold, so it has no currency. - The platform fee is taken in the currency paid.
Every SKU read carries currency, currencyAddress, currencySymbol and currencyDecimals. An order carries currencyKind, currencySymbol and currencyDecimals beside currency (the address), and the receipt's currency is what the buyer actually paid in.
Paying in the chain's coin. On EVM, payment.calls has no approve, payment.calls.pay.value is the total, and permit is null. On Solana, payment.transaction has the same shape as for a token, made of plain SOL transfers.
It is still the buyer paying you. Choosing the coin or USDC changes nothing about custody: the buyer's own transaction pays you (and the fee, if any) directly, exactly as with your token. Chain Daddy never holds the payment. A payment in another currency than the order's, such as your token sent against a USDC order, does not pay the order: it is recorded as an order.mismatch event and grants nothing (see What you are responsible for).
Limits on a sale
| Field | Effect |
|---|---|
maxSupply | Total that can ever be sold. Stock is held while an order waits for payment and released if it expires |
maxPerWallet | Most one wallet may own (forced to 1 for non_consumable) |
saleStartsAt, saleEndsAt | The sale window |
rule | On a SKU for sale: who may buy it. On an unlock: who holds it |
sortOrder | Order in the catalog |
Rules
One small language decides who may buy an item, who holds an unlock and who may claim a drop.
{
"all": [
{ "hold": { "min": "1000000000000000000000" } },
{ "hold": { "min": "5", "token": "0x…", "chainId": "eip155:8453" } },
{ "owns": { "sku": "car.red", "min": 1 } },
{ "any": [{ "owns": { "sku": "pass.s1" } }, { "not": { "owns": { "sku": "banned" } } }] }
]
}| Node | True when |
|---|---|
all / any | Every / at least one child is true |
not | Its child is false |
hold | The wallet holds at least min base units of your token, or of token on chainId when given. Read live |
owns | The wallet owns at least min (default 1) of a SKU in your store |
A rule may nest 4 levels deep and have 32 nodes; anything larger is rejected when you save it.
Rewards: drops
A drop gives items away. Nobody pays, no tokens move and nothing costs gas.
| Mode | How items arrive |
|---|---|
claim | You share a link; eligible wallets claim from it |
push | You upload a list of wallets; activating the drop delivers to all of them |
award | Your game server awards a wallet, e.g. for finishing a race |
{
"name": "Podium finish",
"mode": "award",
"items": [{ "sku": "boost", "quantity": 3 }],
"perWalletLimit": 5,
"maxClaims": 10000,
"startsAt": "2026-10-01T00:00:00Z",
"endsAt": "2026-12-31T00:00:00Z"
}chaindaddy iap drop create 3f0c… --file podium.json --json # starts as draft
chaindaddy iap drop status 3f0c… <dropId> active
chaindaddy iap drop award 3f0c… <dropId> --wallet 0xabc… --idempotency-key race-7731-podiumaudience takes a rule (holders of at least X, owners of a SKU), and allowlistOnly: true limits a drop to the wallets you add with chaindaddy iap drop recipients <store> <drop> --csv wallets.csv. perWalletLimit, maxClaims and the window are enforced on every claim and award, so a bug or a replay in your game cannot give out more than you set.
The Item showcase on your token page
No game needed: add the Item showcase widget from Add Widget on your token page, and visitors can buy your items right there. Each item shows its picture, name and price (with the dollar price when you priced it in dollars) and a Buy button that opens the page's purchase sheet. Items that can't be bought right now say why: sold out, holders only, or not on sale.
What a visitor already holds is marked on the card: Owned for a one-off item, and Buy again with the amount held for items that stack. Under your items, a signed-in visitor sees everything they hold from your store, including live unlocks, with amounts and expiry dates. A visitor who isn't signed in sees a link to sign in.
Open the widget's settings (the gear) to change:
| Setting | Default | What it does |
|---|---|---|
| Title | Items | The heading above your items. Leave it blank for none. |
| Items to show | empty | The items to feature, in order. Empty shows every item that has a price. On the widget itself, Choose items lets you tick them from your store and put them in order. |
| Show the viewer's items | on | The "Your items" list under your items. |
| Columns | 2 per row | 2 or 3 item cards per row. |
The catalog and prices always come from your store: add, price and archive items in the developer portal (Items in your store). Only active items can be shown, and an archived item drops out of the widget by itself. Visitors see nothing until your store has something to show; you see a hint with a link to add items.
Selling from an app on your token page
The convenient path when your game runs as an app on your token page: no server, no payment code. The app never touches the player's wallet or the amounts; it asks the page to run the purchase:
- Declare the
iap:purchasepermission in your manifest. The install screen shows it as "Sell items from this token's store". - Dispatch the action from your widget (see Actions):
const res = await onAction({
type: 'iap-purchase',
sku: 'car.red',
quantity: 1,
appAccountToken: player.id, // optional: your own id for this player, echoed on the order and receipt
});
// res.data: { status: 'confirmed' | 'paid' | 'cancelled' | 'failed', orderId?, receipt? }The page opens its own purchase sheet, the player approves in their wallet, and the action resolves. paid means the payment is on chain and still gathering confirmations; the item arrives when it reaches confirmed.
To show the catalog and the player's items, read with api-call:
await onAction({ type: 'api-call', method: 'GET', endpoint: '/api/v2/iap/public/stores?chainKey=base&crownId=8' });
await onAction({ type: 'api-call', method: 'GET', endpoint: `/api/v2/iap/me/entitlements?storeId=${storeId}` });Apps may read /api/v2/iap/public/…, /api/v2/iap/me/… and /api/v2/iap/orders/…, with the viewer's own session.
How a purchase settles
pending ──▶ paid ──▶ confirmed ──▶ refunded
│
└──▶ expired| Status | Meaning |
|---|---|
pending | The price, quantity, wallet and payees are locked for about 20 minutes (5 for a dollar price converted to your token or the coin) while the buyer pays |
paid | The payment is on chain and gathering confirmations |
confirmed | Enough confirmations: the items are in the wallet and a signed receipt exists |
expired | The window passed with no payment. Held stock is released |
refunded | You refunded the buyer and recorded it. The items and the receipt are revoked |
The number of confirmations each chain needs is in GET /api/v2/iap/config; Solana orders confirm when the payment is finalized. A payment that disappears in a chain reorganisation before it is confirmed sends the order back to pending, and it can still be paid before its deadline.
An order reads paid within a couple of seconds of its payment landing on chain. How long it then takes to read confirmed, at most:
| Chain | Confirmations | Payment landed → confirmed |
|---|---|---|
| Base | 5 | ~10 seconds |
| Arbitrum One | 20 | ~7 seconds |
| BNB Chain | 15 | ~13 seconds |
| Solana | finalized | ~15 seconds |
| Ethereum | 12 | ~2¼ minutes |
| Polygon | 64 | ~2¼ minutes |
These follow the chain's own block times, so a congested or quiet chain can take a little longer.
Stray payments never change an order. The checkout records a payment against the whole order (its id, currency, payees, amounts and deadline), not the order id alone. So a payment that names an order's id with another currency, payee or amount cannot block the real one: the buyer still pays as normal, and the stray payment is recorded as an order.mismatch event and grants nothing. The same order can only be paid once, but a client that changes the deadline can pay it a second time; that is recorded as order.duplicate_payment and grants nothing more. Neither happens with the payment block used as given.
Verifying receipts on a game server
Every confirmed order gets a receipt: a compact JWS signed with Ed25519 (alg: EdDSA). Its payload:
{
"iss": "https://chaindaddy.io",
"typ": "iap-receipt+v1",
"receiptId": "…",
"orderId": "0x…",
"storeId": "3f0c…",
"chainId": "eip155:8453",
"crownId": 8,
"sku": "car.red",
"skuType": "non_consumable",
"quantity": 1,
"wallet": "0xabc…",
"appAccountToken": "player-42",
"currency": { "address": "0x…", "symbol": "FLUXGP", "decimals": 18 },
"amount": "5000000000000000000000",
"feeAmount": "0",
"payment": { "txHash": "0x…", "logIndex": 3, "blockNumber": 51900000, "payer": "0xabc…" },
"purchasedAt": "2026-10-02T18:04:11Z",
"confirmedAt": "2026-10-02T18:04:33Z",
"expiresAt": null,
"iat": 1791050673
}currency is what the buyer paid in: your token, the chain's coin (the zero address on EVM, 11111111111111111111111111111111 on Solana) or the dollar stable. amount and feeAmount are base units of it.
A receipt for a dollar-priced item also carries priceUsdMicros (per item) and usdPerToken (the dollar price of one whole unit of currency the order was quoted at, a decimal string; "1" for USDC). Neither field appears on a fixed-price receipt, so parse them as optional.
Offline, against the public keys at GET /api/v2/iap/jwks (current and previous, so rotation does not break you):
import { createRemoteJWKSet, jwtVerify } from 'jose';
const JWKS = createRemoteJWKSet(new URL('https://api.chaindaddy.io/api/v2/iap/jwks'));
export async function verifyReceipt(jws: string, storeId: string) {
const { payload } = await jwtVerify(jws, JWKS, {
issuer: 'https://chaindaddy.io',
algorithms: ['EdDSA'],
});
if (payload.typ !== 'iap-receipt+v1') throw new Error('not a store receipt');
if (payload.storeId !== storeId) throw new Error('receipt is for another store');
return payload; // grant by payload.sku, payload.quantity, payload.wallet
}Online, to also learn whether it has since been refunded:
curl -X POST https://api.chaindaddy.io/api/v2/iap/receipts/verify \
-H "Content-Type: application/json" \
-d '{"receipt":"eyJhbGciOiJFZERTQSIs…"}'It returns the decoded receipt and its current status, valid or revoked. An offline check proves a receipt was issued; only the online check (or the item ledger) knows about a later refund. For most games the simplest source of truth is the ledger itself: GET …/entitlements?wallet=.
Store keys
A store key is how a game server talks to your store. It works on one store and only for the scopes you give it. Give each server the least it needs.
| Scope | Allows |
|---|---|
catalog:write | Create and change SKUs |
orders:read | Read orders |
orders:write | Create orders for a wallet |
entitlements:read | Read what wallets own |
entitlements:write | Grant, consume and revoke items |
drops:write | Create and change drops |
rewards:award | Award a wallet from an award drop |
events:read | Read the event feed |
chaindaddy iap key create 3f0c… --name game-server --scope entitlements:read entitlements:write
chaindaddy iap key list 3f0c…
chaindaddy iap key revoke 3f0c… <keyId>The secret (cd_iap_…) is shown once, when the key is created. Store it in your secrets manager; lost keys cannot be recovered, only revoked and replaced. Only your account credentials can create, list or revoke keys. A revoked key stops working within a minute.
Rate limits
Calls made with a store key are limited per key, not per IP address, so several games behind one address, or on one host, each get their own budget:
| Budget | Limit | Counted per |
|---|---|---|
| Each store key | 2,400 requests a minute | key |
| All store keys calling from one IP address | 9,600 requests a minute | IP address |
| Unknown or revoked keys | 60 requests a minute | IP address |
Every response carries X-RateLimit-Limit and X-RateLimit-Remaining for the budget it drew on. Past a limit the API answers 429 with a Retry-After header in seconds; wait that long, then retry. A busy server that needs more than one key's budget can use a key per game or per server.
Retries are safe
Every POST takes an Idempotency-Key header. Send the same key again and you get the first result back instead of a second grant, sale or award. The CLI generates one when you do not pass --idempotency-key, and prints it with --json so an agent can retry with it. Build keys from something stable in your game (race-7731-podium), not a fresh random value per attempt.
Events and webhooks
Everything that changes in your store is written to an append-only event feed in the same moment:
| Event | When |
|---|---|
order.created | An order was quoted |
order.paid | Its payment is on chain, not yet confirmed |
order.confirmed | Confirmed: items granted, receipt issued |
order.expired | Its window passed unpaid |
order.reverted | A paid order's payment left the chain (a reorg); it is pending again |
order.refunded | You recorded a refund |
order.mismatch | A payment named the order but not its quote; the order is unchanged and nothing is granted |
order.duplicate_payment | The order was paid a second time (a client changed its deadline); nothing more is granted |
entitlement.granted | Items were added to a wallet |
entitlement.consumed | Items were spent |
entitlement.revoked | Items were taken back |
drop.claimed | A wallet claimed a drop |
drop.awarded | Your server awarded a wallet |
chaindaddy iap events 3f0c… --after 1200 --json
chaindaddy iap events 3f0c… --follow --json # one event per line, as they happenGET /api/v2/iap/stores/{id}/events?after= returns {entries, last}; pass last back as after to continue. The same events are pushed as webhooks named iap.<type> (iap.order.confirmed, …): to your store's own webhook (below), and to any iap.* webhook the wallet that opened the store has set up.
Purchase notifications
Give your store a webhook and your game server hears about every sale as it confirms, with no polling. Any store can have one: you need the store's own plan (a Developer plan or developer program membership), not the Developer plan the general webhooks need.
Set it in Developer portal → Store → Settings → Purchase notifications, or:
chaindaddy iap webhook set 3f0c… --url https://game.example.com/hooks/chaindaddy
# prints the whsec_… signing secret ONCE: put it in your server's secrets
chaindaddy iap webhook test 3f0c… # sends a signed iap.test event
chaindaddy iap webhook show 3f0c… # the webhook and its last 20 deliveries
chaindaddy iap webhook rotate 3f0c… # a new secret; the old one stops working at once
chaindaddy iap webhook delete 3f0c…Over HTTP it is PUT /api/v2/iap/stores/{id}/webhook {url, events?} (201 with the secret on create, 200 without it on a change), GET, DELETE, POST …/webhook/test and POST …/webhook/rotate-secret. Setting, rotating and removing need your account credentials; a store key with events:read can read the webhook and send a test. The URL must be https:// and reachable from the internet. events narrows what it receives; by default it gets every type below.
| Webhook event | When |
|---|---|
iap.order.created | An order was quoted |
iap.order.paid | Its payment is on chain, not yet confirmed |
iap.order.confirmed | Confirmed: items granted, receipt issued. Deliver on this one |
iap.order.expired | Its window passed unpaid |
iap.order.reverted | A paid order's payment left the chain (a reorg); it is pending again |
iap.order.refunded | You recorded a refund; its items and receipt were revoked |
iap.order.mismatch | A payment named the order but not its quote |
iap.order.duplicate_payment | The order was paid a second time |
iap.entitlement.granted / .consumed / .revoked | A wallet's items changed |
iap.drop.claimed / iap.drop.awarded | A drop was claimed or awarded |
A confirmed purchase arrives like this:
{
"id": "iap_evt_1207",
"type": "iap.order.confirmed",
"created_at": "2026-09-27T09:14:03Z",
"data": {
"eventId": 1207,
"type": "order.confirmed",
"storeId": "3f0c…",
"chainId": "eip155:8453",
"crownId": 8,
"wallet": "0x5a1c…",
"orderId": "0x9e1f…",
"payload": {
"sku": "boost_pack",
"quantity": 1,
"status": "confirmed",
"appAccountToken": "player-7731",
"amount": "50000000000000000000",
"txHash": "0x4b7e…",
"payer": "0x5a1c…",
"receiptId": "0d6f…",
"receipt": "eyJhbGciOiJFZERTQSIs…"
}
}
}appAccountToken is whatever you passed when you created the order, so you know which player to credit. receipt is the signed receipt; verify it as in Verifying receipts and deliver. entitlement.* events carry the sku, the delta and the wallet's quantity after the change. id is the same on every retry of one event, so use it to ignore a repeat.
Every delivery is signed. X-Webhook-Signature is the hex HMAC-SHA256 of the raw request body, keyed with your whsec_… secret. Check it against the raw bytes before you parse them:
import crypto from 'node:crypto';
// app.post('/hooks/chaindaddy', express.raw({ type: 'application/json' }), handler)
function verifyChainDaddy(rawBody, signature, secret) {
const expected = crypto.createHmac('sha256', secret).update(rawBody).digest('hex');
return signature?.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
}
if (!verifyChainDaddy(req.body, req.get('X-Webhook-Signature'), process.env.CHAINDADDY_WEBHOOK_SECRET)) {
return res.status(401).end();
}
const event = JSON.parse(req.body);The other headers are X-Webhook-Event (the type), X-Webhook-Delivery (the id) and X-Webhook-Timestamp. Answer with a 2xx within 5 seconds. A failed delivery is retried with backoff, starting at 1 minute. After 3 failed deliveries in a row the webhook is turned off; saving its URL again turns it back on. chaindaddy iap webhook show lists what was delivered and what your endpoint answered.
If you miss one, reconcile from the feed. Keep the highest data.eventId you have processed and read GET /api/v2/iap/stores/{id}/events?after=<eventId> on start-up and on a timer; its payload is the same object the webhook carries. The webhook makes you fast, the feed makes you complete.
The fee
| Your token | Fee |
|---|---|
| Launched in a Chain Daddy launchpad pool | 0%. We already earn from that pool's trading fees |
| Every other token: made with the Chain Daddy token creator, launched elsewhere, or existing | 2.5% |
The fee is taken at checkout in whatever the buyer pays in (your token, the chain's coin or USDC), out of the listed price. The buyer pays the price you set; you receive the price minus the fee, and the fee goes to Chain Daddy in the same transaction. The store's feeBps field is the rate that applies to it.
Non-custodial
A sale never passes through a Chain Daddy account. On EVM the checkout contract moves the buyer's payment, in your token, the chain's coin or USDC, straight to your payout address (and the fee, if any, to Chain Daddy) in one transaction. It has no owner, no admin and no balance, so there is nothing in it for anyone to take. On Solana the buyer's own transfer pays you directly. An order counts as paid only when that payment is on chain at the required depth.
What you are responsible for
You are the merchant. Chain Daddy records what happened on chain and keeps the item ledger; the rest is yours:
Prices and what an item does in your game.
Delivery. Read the ledger or verify receipts, and honour what they say.
Refunds. A refund is your own transfer back to the buyer from your wallet. Then record it, which revokes the items and the receipt:
bashchaindaddy iap order refund 3f0c… 0x9e1f… --tx 0xRefundTxHash --note "double purchase"Stray and duplicate payments. An
order.mismatchevent means someone paid an order's id but not its quote (a different amount, payee or currency); the payment went wherever that payment sent them, and if they reached you, settle it with the payer yourself. Anorder.duplicate_paymentevent means the buyer paid the same order twice; the second payment reached you, so refund it.
Items are not money. They live off chain, cannot be transferred between wallets and can never be exchanged for tokens or cash. Call them items, skins, passes or whatever your game calls them, never a balance of money.
Related docs
- Actions: dispatching actions from a widget
- App Manifest: permissions your app declares
- Webhooks: signing, retries and subscriptions
- CLI: installing and configuring
chaindaddy