Skip to content

Store and Rewards ​

Sell items for your own coin, give items away as rewards, and unlock perks for the people who hold your coin. Buyers pay you directly on chain. Chain Daddy keeps the catalog, records what each wallet owns, and signs a receipt for every purchase.

  • An item is one thing you sell, known by its key, the sku.
  • An entitlement is the store's record that a wallet owns an item: which one, how many, until when. Your app and your server read entitlements.
  • A store key (cd_iap_…) is what your own server uses to talk to your store.

You can sell with no code, or from your own server:

  • With the Item showcase, a ready-made app for your coin page. No code. See The Item showcase.
  • From an app on your coin page. The app asks the page to run the purchase, and the page handles the wallet. See Selling from an app on your coin page.
  • From your own website, with our checkout. Your server opens a checkout session, the buyer pays on chaindaddy.io and comes back to your site. See Take payments on your site.
  • From your own app or site, with your own UI and a server that talks to the store API. See Sell from your own app.

Apps other developers publish can sell their own items on your page too, once you accept their terms. See Items from apps you installed.

Every step is also an API call and a chaindaddy iap … command. Build against your test store first: the same API, with orders paid by one call and nothing on chain.

Who can open a store ​

Any paid plan opens a store, from Pro ($5 a month) up. Open it from your coin page's manager, with no code, or from the developer portal. No Developer Beta membership is needed.

Opening a store, or opening a paused one again, needs the Seller Terms, which your wallet signs once (free): you are the seller of record, all sales are final, what you may not sell, and Chain Daddy's fee. When the Seller Terms change, your store keeps selling for 30 days on the version you signed, and your page's Store tab asks you to sign the new one. Without a paid plan, store routes answer 403 IAP_PLAN_REQUIRED; without the Seller Terms, opening answers 403 SELLER_TERMS_REQUIRED. GET /api/v2/iap/eligibility tells a signed-in wallet where it stands.

Adding an app that sells opens a store limited to that app's items, on any plan: see Items from apps you installed.

You open a store as a wallet that manages the coin page. Changing the payout address takes the owner, and so does saying whether you sell as a business or a private person: buyers see which, as "Sold by … through Chain Daddy", above the pay button. If the wallet that opened the store loses its plan, your own items stop selling and its store keys stop working until the plan is back; items from apps you installed keep selling. Buyers keep the items and receipts they already have.

Fees ​

Your tokenFee on each sale
Launched in a Chain Daddy launchpad pool0%
Any other token: made with the Chain Daddy token creator, launched elsewhere, or existing2.5%

The buyer pays the price you set. The fee comes out of it at checkout, in whatever the buyer pays in, and the rest goes to your payout address in the same transaction. The store's feeBps field is the rate that applies to it. The same rate applies to the items and payments apps make on your page (Paid Apps fees).

Where you manage it ​

WhatWhere
Opening the store, the Seller Terms and itemsYour coin page's manager, no code needed
Items, orders, customers, store keys, events, settings and the store's webhookDeveloper portal → Store: chaindaddy.io/_developer/store
Your test store and its cd_iap_test_… keysThe same place: switch Live to Test beside the store's name. The first switch creates it (Get a test key)
Rewards: claim links, pushes and drops your server awardsYour token's manager: Community → Rewards
Token prizes your server pays from a prize poolYour token's manager: Community → Airdrops. See Token Prizes
Selling on your coin pageAdd the Item showcase app to the page
Everything, from your server or an agentThe API, chaindaddy iap …, or the MCP tools

The Item showcase on your coin page ​

Add the Item showcase app to your coin page, and visitors can buy your items there. Each item shows its picture, name and price, 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. A signed-in visitor also sees what they already hold from your store.

Open its settings (the gear) to set the title, choose and order the items to show (empty shows every item with a price), show or hide the visitor's own items, and pick 2 or 3 cards per row. Prices and items come from your store, so add, price and archive them in your coin page's manager or the developer portal. Items from apps you installed sell inside those apps, not in the showcase.

A payment link opens one of your items, ready to buy, on your coin page. Put it on your site, in a Discord post, in a menu or in a README:

https://chaindaddy.io/fluxgp/base?address=0x931f…7f22&buy=boost.pack
https://chaindaddy.io/fluxgp/base?address=0x931f…7f22&buy=credits.100&qty=3&ref=discord

Copy the link from the developer portal (Items, Copy link on an active item; Copy embed beside it gives the item's card for your own site), or run chaindaddy iap link <store> <sku> [--qty n] [--ref tag]. Links open your live store.

ParamRequiredWhat it is
addressrecommendedYour coin's contract or mint address, so the link opens your page
buyyesThe item's sku
qtyno1 to 99, default 1
refnoYour tag for where you shared it: 1 to 32 of a-z, 0-9, ., _, -

The link opens the purchase sheet. The price and payees come from your store, not from the link. An item that can't be bought right now opens the sheet with the reason.

The order keeps ref: it shows in Orders, on the order, and in every order.* event. To credit a user's account, key on the buyer's wallet: a link does not carry appAccountToken.

Selling from an app on your coin page ​

When your app runs on your coin page, it needs no server and no payment code. It asks the page to run the purchase:

  1. Declare iap:purchase in your manifest. The install screen shows it as "Sell items from this token's store".
  2. Send the iap-purchase action:
typescript
const res = await onAction({
  type: 'iap-purchase',
  sku: 'badge.gold',
  quantity: 1,
  appAccountToken: user.id, // optional: your own id for this user, echoed on the order and receipt
});
// res.data: { status: 'confirmed' | 'paid' | 'cancelled' | 'failed', orderId?, receipt? }

The page shows its purchase sheet, the user approves in their wallet, and the action resolves. paid means the payment is on chain and still confirming; the item arrives when it reaches confirmed.

Read the catalog and the user's items with api-call, with the viewer's own session. Apps may read /api/v2/iap/public/…, /api/v2/iap/me/… and /api/v2/iap/orders/…:

typescript
// chainKey and appData.tokenId are props: they name the token page your app is on
await onAction({ type: 'api-call', method: 'GET', endpoint: `/api/v2/iap/public/stores?chainKey=${chainKey}&crownId=${appData.tokenId}` });
await onAction({ type: 'api-call', method: 'GET', endpoint: `/api/v2/iap/me/entitlements?storeId=${storeId}` });

Handing out a reward from the app ​

An app can claim a claim-link drop for the user in front of it, with their own session. It needs iap:purchase.

typescript
const res = await onAction({ type: 'api-call', method: 'POST', endpoint: `/api/v2/iap/drops/${slug}/claim` });
// res.ok: true → res.data.granted lists the items; the store event store:entitlements follows
// res.ok: false → res.error says why (not in the audience, already claimed…); res.status is the HTTP status

The drop's audience, allowlist, per-wallet limit and dates apply just as on its claim page.

An app that sells its own items on any page that installs it, and pays its developer a share, is a paid app.

Spending from the app ​

An app can spend a consumable the viewer owns, with no server: a single-player game sells lives with iap-purchase and spends one when a run starts. It takes two halves, one from the store's owner and one from the app. Without both, the spend is refused.

  1. The store's owner sets the item's spend limit. maxAppSpend is the most an app may spend of that item in one request, from 1 to 1,000,000. Until it is set, no app can spend the item. Set it to what one use costs: 1 for a life, the price of the dearest upgrade for coins. In the portal it is the item's "Apps can spend, at a time" field; from the CLI, --max-app-spend <n> (and --clear-app-spend).

    json
    { "sku": "life", "type": "consumable", "name": "Life", "priceUsdMicros": 250000, "maxAppSpend": 1 }
  2. The app declares iap:consume in its manifest. The install screen shows it as "Spend items a visitor owns in this token's store, on their tap".

  3. The app sends iap-consume from the viewer's tap:

    typescript
    startButton.onclick = async () => {
      const res = await onAction({ type: 'iap-consume', sku: 'life', key: `run-${runId}` });
      if (res.ok) startRun(res.data.remaining);                  // { sku: 'life', remaining: 2 }
      else if (res.code === 'INSUFFICIENT_QUANTITY') offerToBuy('life');
    };

What the page checks and shows the viewer, how key makes a retry safe, and every refusal are in the iap-consume reference. An unreviewed build can't spend; chaindaddy app dev answers the action from its sample store, refusals included. Uninstalling the app, or clearing maxAppSpend, stops it at once. Your store's entitlement.consumed event names the app (refType: "app", refId the app's id).

Spend on your server for anything competitive or prize-bearing. This spend is made with the viewer's own session, so a viewer can make it without your app, or run a changed copy of your app that skips it. For an entry fee to a ranked run, a raffle ticket, or anything another player or a payout depends on, leave maxAppSpend unset and call consume from your server with a store key.

A file your app ships can be kept for buyers alone: a level pack, a track, a skin. Put it in your package under paid/<sku>/, and Chain Daddy serves it only to a signed-in wallet that holds <sku> in the store of the coin page your app is on.

my-game/
  capp.json
  engine/                 public, like assets/ and your bundle
  paid/
    track-pack/           only a wallet that holds "track-pack"
      monza.bin
      audio/lap.ogg
    season/               only a wallet that holds "season"
      skins.pck

Your app asks the page for a file with paid-file, and fetches the URL it gets back. A Godot or Unity game does the same through its bridge: Engine apps.

typescript
async function loadTrackPack() {
  const res = await onAction({ type: 'paid-file', path: 'paid/track-pack/monza.bin' });
  if (res.ok) return (await fetch(res.data.url)).arrayBuffer();   // the URL lasts five minutes, for this file only
  if (res.code === 'SIGN_IN_REQUIRED') return showSignIn();
  if (res.code === 'NOT_OWNED') return offerToBuy('track-pack');  // iap-purchase, then ask again
}
  • The folder's name is the item's sku. One folder per item, beside your manifest. A file directly in paid/ is refused, and a paid folder inside engine/ or assets/ is public.
  • Who is served. A wallet that holds the item at the moment your app asks: an item owned once, a pass until it expires, an unlock while its rule holds. A consumable is refused: sell a pack as an item owned once or a pass. A bundle grants its contents, so name the folder for the item the wallet ends up holding.
  • Which store. The store of the coin page your app is on. Each page that installs your app has its own store, whose owner sets the price and can give the item away. On a page whose store has no item with that sku, nobody is served.
  • A download cannot be taken back. A refund or a pass running out stops new downloads; the copy a buyer already downloaded is theirs. Use paid files for content, not for anything a ranked result or a prize depends on.
  • Size and names. Paid files count with engine/ against your app's size tier (16 MB downloaded; 64 MB on request), and one file may be 64 MB. Names use letters, digits, space, ., _, - and / for folders.
  • Uploaded builds only. A build you publish from your own storage cannot carry paid files: chaindaddy app publish refuses a package with paid/.

chaindaddy app scan lists your paid folders and warns about one whose name no store could give an item. chaindaddy app dev serves them from your project's paid/ folder: its owns paid files switch and the signed-out viewer show your app both refusals. On your own coin page, your unreviewed preview loads your paid files with no purchase.

Hearing when a purchase settles ​

An app with iap:purchase can subscribe to store events instead of polling:

typescript
const unsubscribe = props.subscribeStore?.((event) => {
  if (event.type === 'store:order') {
    // Only the app that asked for this purchase hears it.
    // event: { orderId, sku, status: 'confirmed' | 'failed', receipt? }
    if (event.status === 'confirmed') unlock(event.sku, event.receipt);
  }
  if (event.type === 'store:entitlements') {
    // The viewer's items in this store changed. Read them again.
    refreshInventory();
  }
});
// later: unsubscribe?.()
  • store:order goes only to the app whose purchase closed as paid. The page follows that order for up to 45 minutes and sends confirmed (with the signed receipt) or failed.
  • store:entitlements goes to every app on the page that holds iap:purchase, whenever a purchase on the page confirms or an app spends an item.
  • subscribeStore exists only for apps in their own frame (every app that isn't ours), so check for it before calling it.

What the buyer sees ​

The Item showcase, payment links and the iap-purchase action all open the coin page's purchase sheet:

  • The price locks when the buyer taps Buy.
  • All sales are final. The buyer ticks "Digital item, delivered immediately. All sales are final." before their wallet opens.
  • A double tap, a retry or a reopened sheet returns the same order.
  • The buyer can close the sheet once the payment is sent. The item arrives when the order confirms.
  • A buyer who is short is offered what they lack. For your token, the sheet opens the page's Buy panel sized to the shortfall. For USDC it offers a swap from the chain's coin, or a bridge from another EVM chain.

Sell from your own app ​

1. Open the store and add an item ​

You need a paid plan and the Seller Terms signed (Who can open a store); until both are in place, opening answers with the one that is missing and its link.

bash
npm install -g @chaindaddy/cli@beta            # once; needs Node.js 20 or later
chaindaddy login                               # once: sign in through your browser (see the CLI reference)

# Open the store for your token page, named by its ticker
chaindaddy iap store open --page FLUXGP
#   → ✓ Store open … token: FLUXGP on base, token page #8 … fee: 2.50%

# Add an item and put it on sale (see Items for the JSON)
chaindaddy iap sku create FLUXGP --file badge-gold.json
chaindaddy iap sku update FLUXGP badge.gold --status active

# A store key for your server: create orders, read and spend entitlements
chaindaddy iap key create FLUXGP --name app-server \
  --scope orders:read orders:write entitlements:read entitlements:write
#   prints the cd_iap_… secret ONCE: put it in your server's secrets

Wherever a command takes a store, give your coin page's ticker (or its address, chaindaddy.io/FLUXGP/base) or the store's id, which the examples write as 3f0c….

Over HTTP a coin page is named by two ids: chainKey, its chain (such as base), and crownId, its registration id, the number after token page # above. Opening a store is POST /api/v2/iap/stores with {"chainKey":"base","crownId":8}. A coin page has one store, so opening it again returns the same one. EVM chains and Solana are both supported: GET /api/v2/iap/config lists the chains, the confirmations each needs, and the currencies each offers besides your token.

Sales pay out to payoutAddress, which starts as the owner's wallet. When a page changes owner, its store stops selling until the new owner opens it; payouts then go to the new owner.

bash
chaindaddy iap store update FLUXGP --payout 0xYourTreasury   # owner only
chaindaddy iap store update FLUXGP --pause                    # stop selling; --resume to restart

2. Your server creates the order ​

When a user taps Buy, your server creates an order for that user's wallet. appAccountToken is your own id for the user. It comes back on the order, the receipt and every event, so you can match a payment to an account.

bash
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":"badge.gold","quantity":1,"wallet":"0xabc…","appAccountToken":"user-42"}'

CLI: chaindaddy iap order create 3f0c… --sku badge.gold --wallet 0xabc… --app-account-token user-42.

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:

json
{
  "id": "0x9e1f…",
  "storeId": "3f0c…",
  "sku": "badge.gold",
  "quantity": 1,
  "wallet": "0xabc…",
  "appAccountToken": "user-42",
  "ref": null,
  "currency": "0xToken…",
  "currencyKind": "token",
  "currencySymbol": "MYTOKEN",
  "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": "My Token", "version": "1" },
    "permitTypedData": { "domain": { "…": "…" }, "types": { "…": "…" }, "primaryType": "Permit", "message": { "…": "…" } },
    "calls": {
      "approve": { "to": "0xToken…", "data": "0x095ea7b3…", "value": "0" },
      "pay": { "to": "0xCheckout…", "data": "0x…", "value": "0" }
    }
  }
}

amount and every other amount is in base units: the amount with the currency's decimals written out. With 18 decimals, 5,000 tokens is 5,000 followed by 18 zeros. Pass payment to your app client. Use one Idempotency-Key per purchase attempt, such as your cart id (Retries are safe).

3. The user pays from their wallet ​

The client pays:

  • calls: send approve (the ERC-20 approval), then pay. An item paid in the chain's coin has no approve, and pay.value is the total.
  • permitTypedData: present when the currency supports permits (permit.supported; permit is null for the chain's coin). The user signs it, and one transaction calls payWithPermit on the checkout.

With viem:

typescript
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 that pays you (and the fee, if any) directly. The user's wallet signs and sends it, and its signature takes the place of the transaction hash below.

4. Wait for confirmed ​

An order moves to paid within seconds of the payment landing. If you already have the hash, submit it to check that transaction at once:

bash
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":"…"}

Then poll the order with the store key until it is confirmed, or subscribe to the iap.order.confirmed webhook:

bash
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: don't hand over the item yet. Polling every second or two reads confirmed as soon as it is deep enough.

An order that touches a chain also carries a verification block, to check the payment on chain yourself: Verify a payment. Test orders carry none.

5. Deliver ​

A confirmed order carries a signed receipt: verify it and deliver. Or read the wallet's entitlements, which are the source of truth for what a user owns, and spend from them on your server:

bash
chaindaddy iap entitlements 3f0c… --wallet 0xabc… --json          # GET …/entitlements?wallet=
chaindaddy iap consume 3f0c… --wallet 0xabc… --sku credits --qty 1 --idempotency-key job-7731-credit-2

Items ​

Each item has a sku key, unique in your store: lowercase letters, digits, ., _ and -, up to 64 characters, starting with a letter or digit. A fixed price is a base-unit string of the item's currency; the CLI's --price-tokens 5000 converts for you. We recommend pricing in dollars and taking payment in your token.

TypeA purchase givesGood for
consumablequantity added to a balance; your server spends it with consume, or an app on your coin page does when you let apps spend itCredits, boosts
non_consumableOwned, at most one per walletBadges, themes, a feature bought once
timedAccess until an expiry, extended by durationSeconds × quantity from now or from the current expiry, whichever is later. Nothing renews automaticallyMemberships, passes
unlockNot sold. Held while its rule holds, checked liveHolder perks

New items start as draft. Set status to active to list them. Archive one to take it down (chaindaddy iap sku archive <store> <sku>); that does not take it away from anyone who has it.

A badge:

json
{
  "sku": "badge.gold",
  "type": "non_consumable",
  "name": "Gold badge",
  "description": "A gold badge beside your name.",
  "imageUrl": "https://app.example/items/badge-gold.png",
  "price": "5000000000000000000000",
  "metadata": { "badgeId": "gold" },
  "status": "active"
}

metadata is any JSON your app needs to render or apply the item. It comes back on every read.

A credit pack. A bundle grants other items instead of itself: buying credits.100 adds 100 to the user's credits balance. credits has no price, so it can only be given as a reward. Spending more than a wallet has answers 409 INSUFFICIENT_QUANTITY.

json
[
  { "sku": "credits", "type": "consumable", "name": "Credit", "status": "active" },
  {
    "sku": "credits.100",
    "type": "consumable",
    "name": "100 credits",
    "price": "1000000000000000000000",
    "grants": [{ "sku": "credits", "quantity": 100 }],
    "status": "active"
  }
]

A 90-day membership, on sale for a window. Buying twice gives 180 days.

json
{
  "sku": "member.90d",
  "type": "timed",
  "name": "90-day membership",
  "price": "20000000000000000000000",
  "durationSeconds": 7776000,
  "saleStartsAt": "2026-10-01T00:00:00Z",
  "saleEndsAt": "2026-12-31T00:00:00Z",
  "status": "active"
}

A holders-only perk, held by any wallet with at least 1,000 tokens for as long as it keeps them. It shows in that wallet's items with "source": "unlock".

json
{
  "sku": "access.early",
  "type": "unlock",
  "name": "Early access",
  "rule": { "hold": { "min": "1000000000000000000000" } },
  "status": "active"
}

Items live off chain. They can't be transferred between wallets or exchanged for tokens or cash, so don't describe them as a balance of money. Nothing you sell may have a chance-based outcome (no loot boxes, mystery boxes, raffles or lotteries), and nothing may be presented as an investment, a return, a share of revenue, or an allocation, airdrop or presale of a token.

Price in dollars ​

Set priceUsdMicros instead of price and the item costs about the same in dollars whatever your token trades at. The unit is a millionth of a dollar, so $5 is 5000000. An item with no currency is still paid in your token.

json
{
  "sku": "credits.500",
  "type": "consumable",
  "name": "500 credits",
  "priceUsdMicros": 5000000,
  "minPrice": "1000000000000000000000",
  "maxPrice": "500000000000000000000000",
  "status": "active"
}

CLI: --price-usd 5 --min-price-tokens 1000 --max-price-tokens 500000. An item has either price or priceUsdMicros; setting one clears the other.

Each order converts the dollar price to an exact amount at that moment and locks it:

  • The rate is your token's live market price (or the chain's coin's).
  • The amount is rounded up to 4 significant figures.
  • minPrice and maxPrice (optional, base units per item) cap the conversion.
  • An order converted to your token or the coin can be paid for 5 minutes. The next order gets a fresh amount.
  • An item paid in USDC needs no rate: $5 is exactly 5 USDC.

When there is no reliable price right now, creating an order answers 503 PRICE_UNAVAILABLE. It usually clears within a minute, so ask the user to try again shortly. Fixed prices and USDC prices do not depend on it.

A dollar-priced order and its receipt also carry priceUsdMicros and usdPerToken, the dollar price of one whole unit of the currency the quote used. The store's price field (chaindaddy iap store show) shows the current rate, usdPerToken and usdPerNative, or unavailable with the reason. In the public catalog a dollar-priced item has "priceIsEstimate": true and an estimated price for display ("$5.00 ≈ 140,100 MYTOKEN"); the amount locked on the order is the one that counts.

What the buyer pays in ​

Items are paid in your token by default. When an item suits another currency better, set its currency:

currencyThe buyer pays inprice, minPrice, maxPrice are
token (default)Your tokenBase units of your token
nativeThe chain's coin: ETH, BNB, POL, SOL…Base units of the coin (wei, lamports)
usdcThe chain's dollar stablecoin: USDC (USDG on Robinhood Chain)Base units of the stablecoin (check its decimals: BNB Chain's is 18)

GET /api/v2/iap/config lists each chain's currencies with address, symbol and decimals. CLI: --currency usdc.

An update that changes currency on a fixed-price item must set price (or priceUsdMicros) in the same call, and it clears minPrice and maxPrice. A dollar price carries over.

Let the buyer choose the currency ​

A dollar-priced item can take more than one currency, and the buyer picks one when they pay. List them in acceptedCurrencies:

json
{
  "sku": "tee",
  "type": "consumable",
  "name": "Tee",
  "priceUsdMicros": 5000000,
  "acceptedCurrencies": ["token", "usdc", "native"]
}
  • Each entry is token, usdc or native, and must exist on your store's chain.
  • The list must include the item's own currency (your token unless you set another). That one stays the default.
  • Only a dollar-priced item can take more than one currency, and it takes no minPrice or maxPrice.
  • A list that is only the item's own currency is the same as no list.

The buyer sees a picker on the pay page. Each choice shows the price in that currency, and a currency that can't take the order right now is shown but can't be picked. Your server picks one by passing currency (usdc, native or token) when it creates the order; without one, the order is in the item's own currency. The order locks the amount in the chosen currency. An Idempotency-Key is bound to the currency too, so the same key with another currency is refused (IDEMPOTENCY_KEY_REUSED).

GET /api/v2/iap/public/stores/{storeId}/skus/{sku}/currencies?quantity=1 lists the item's currencies with kind, address, symbol, decimals and available. A currency that is not available carries a code and message, such as CURRENCY_PAUSED when trading in that coin is too thin to take payment safely.

Revenue splits ​

An item can pay other wallets a share of every sale: a collaborator, an artist, a charity. The buyer's one payment pays each of them directly, beside your payout.

json
{
  "sku": "skin.neon",
  "type": "non_consumable",
  "name": "Neon skin",
  "priceUsdMicros": 3000000,
  "splits": [{ "address": "0x7777777777777777777777777777777777777777", "bps": 2000 }],
  "status": "active"
}
  • bps is basis points (2000 = 20%) of your share, the price after the fee.
  • Up to 2 splits, or 3 on a launchpad token (no fee). Together they stay under 100%.
  • Each split rounds down and your payout gets the rest. On a very small order a split that rounds to nothing is left off.
  • Addresses are wallets on the store's chain, each once, and not your payout wallet. On Solana, an item paid in SOL can only split to a wallet that already holds some SOL.
  • An order locks its splits, and its receipt lists what each wallet was paid. Changing an item's splits only affects later orders.
  • The page's owner sets splits, signed in. A manager or a store key can edit everything else on an item; a change to its splits is refused with 403 SPLITS_OWNER_ONLY.

CLI: --split <address>:<percent>, repeated, and --clear-splits.

Limits on a sale ​

FieldEffect
maxSupplyTotal that can ever be sold. Stock is held while an order waits for payment and released if it expires
maxPerWalletMost one wallet may own (always 1 for non_consumable)
saleStartsAt, saleEndsAtThe sale window
ruleWho may buy it (on an unlock: who holds it)
sortOrderOrder in the catalog

Rules ​

A rule decides who may buy an item, who holds an unlock and who may claim a drop.

json
{
  "all": [
    { "hold": { "min": "1000000000000000000000" } },
    { "hold": { "min": "5", "token": "0x…", "chainId": "eip155:8453" } },
    { "owns": { "sku": "badge.gold", "min": 1 } },
    { "any": [{ "owns": { "sku": "member.90d" } }, { "not": { "owns": { "sku": "banned" } } }] }
  ]
}
NodeTrue when
all / anyEvery / at least one child is true
notIts child is false
holdThe wallet holds at least min base units of your token, or of token on chainId. Read live
ownsThe wallet owns at least min (default 1) of an item in your store

A rule may nest 4 levels deep and have 32 nodes.

Rewards: drops ​

A drop gives items away. To give away your token instead, use a token prize pool.

ModeHow items arrive
claimYou share a link; eligible wallets claim from it
pushYou upload a list of wallets; activating the drop delivers to all of them
awardYour server awards a wallet (POST …/drops/{dropId}/award, scope rewards:award), for example when a user reaches a milestone
json
{
  "name": "Milestone reward",
  "mode": "award",
  "items": [{ "sku": "credits", "quantity": 30 }],
  "perWalletLimit": 5,
  "maxClaims": 10000,
  "startsAt": "2026-10-01T00:00:00Z",
  "endsAt": "2026-12-31T00:00:00Z"
}
bash
chaindaddy iap drop create 3f0c… --file milestone.json --json          # starts as draft
chaindaddy iap drop status 3f0c… <dropId> active
chaindaddy iap drop award 3f0c… <dropId> --wallet 0xabc… --idempotency-key user-42-milestone-10

audience takes a rule, 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 dates apply to every claim and award.

Items from apps you installed ​

An app another developer publishes can sell its own items on your page: passes and things owned once, priced in dollars and paid in USDC or your token. When you add one, you see every item, its price, the developer's share, the fee and any install price, and the page's owner accepts them (Adding an app that sells). You are the seller of record of those items on your page.

  • Your store. Accepting opens a store on your page if it has none, on any plan. A store opened this way carries installed apps' items only: adding an item of your own answers 409 STORE_APPS_ONLY until you open a full store.
  • In your catalog. The app's items show among yours with appId and appItemKey. Their sku starts with app., a prefix no item of yours may use.
  • Locked. No manager and no store key can change, grant or archive an app's item: those answer 409 APP_ITEM_LOCKED. The developer's share stays as you accepted it.
  • When the developer changes one. A new price, currency, type, duration or share pauses that item on your page until the owner accepts the new terms. A new name, description or picture shows at once.
  • Each sale pays your payout wallet, Chain Daddy's fee and the developer's share, in the buyer's own transaction. It is final: a refund can't be recorded on it (409 FINAL_SALE).
  • Install licenses and payments an app makes through the checkout (pay) pass through your store but are not your sales: they are not in your orders or stats.
  • On your own website. A checkout session can sell an app's item from your site when the app's developer allows it for that item. Until they do, opening one answers 409 APP_ITEM_NOT_SOLD_HERE. The sale pays the same three wallets.
  • Removing the app stops its sales on your page. Buyers keep what they bought.

How a purchase settles ​

pending ──▶ paid ──▶ confirmed ──▶ refunded
   │
   └──▶ expired
StatusMeaning
pendingPrice, quantity, wallet and payees are locked while the buyer pays
paidThe payment is on chain and gathering confirmations
confirmedThe items are in the wallet and a signed receipt exists
expiredThe deadline passed with no payment. Held stock is released
refundedThe store's owner paid the buyer back and recorded it. The items and the receipt are revoked

GET /api/v2/iap/config gives the confirmations each chain needs; Solana orders confirm when the payment is finalized. On Base, Arbitrum, BNB Chain and Solana that takes seconds; on Ethereum, about two minutes. A payment undone by a chain reorganisation before it confirms sends the order back to pending.

A payment that names an order's id but not its exact quote (another currency, payee or amount) can't pay or block that order: it is recorded as order.mismatch and grants nothing. Paying the same order twice is recorded as order.duplicate_payment and grants nothing more.

Verifying receipts on your server ​

Every confirmed order gets a receipt: a compact JWS signed with Ed25519 (alg: EdDSA). Its payload:

json
{
  "iss": "https://chaindaddy.io",
  "typ": "iap-receipt+v1",
  "receiptId": "…",
  "orderId": "0x…",
  "storeId": "3f0c…",
  "chainId": "eip155:8453",
  "crownId": 8,
  "sku": "badge.gold",
  "skuType": "non_consumable",
  "quantity": 1,
  "wallet": "0xabc…",
  "appAccountToken": "user-42",
  "currency": { "address": "0x…", "symbol": "MYTOKEN", "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,
  "livemode": true
}
  • currency is what the buyer paid in: your token, the chain's coin (the zero address on EVM, 11111111111111111111111111111111 on Solana) or the stablecoin. amount and feeAmount are base units of it.
  • A dollar-priced receipt also carries priceUsdMicros and usdPerToken. Parse both as optional.
  • livemode is false on a test store's receipt.
  • An order made through an app adds appId (the app), appItem (the app's own key, on its own items) and kind (item, licence or payment). They are absent on other receipts. See Paid Apps.

Offline, against the public keys at GET /api/v2/iap/jwks (current and previous): check the signature (EdDSA), that iss is https://chaindaddy.io, that the typ claim is iap-receipt+v1 and that storeId is your store, then grant by sku, quantity and wallet, once per orderId. Verifying tokens has the check as one TypeScript file, with test vectors for any language.

Online, to also learn whether a refund has since been recorded:

bash
curl -X POST https://api.chaindaddy.io/api/v2/iap/receipts/verify \
  -H "Content-Type: application/json" \
  -d '{"receipt":"eyJhbGciOiJFZERTQSIs…"}'

It answers 200 with valid, status and, when the signature is ours, the decoded receipt as payload:

statusvalidMeaning
validtrueThe receipt is ours and its order stands. Deliver
revokedfalseThe receipt is ours, and a refund was recorded since. Do not deliver
invalidfalseNot a receipt we signed: a bad signature, a changed payload, or not a receipt at all. error says which, and there is no payload
unknownfalseThe signature is ours, but this API has no record of the receipt

Deliver only on "valid": true. Only the online check (or the item ledger, GET …/entitlements?wallet=) knows about a later refund.

Authentication ​

Store routes take one credential, as Authorization: Bearer …. Your account key also works as X-API-Key: cd_live_….

CredentialUse it for
Your account: a cd_live_ API key or a wallet sessionOpening stores, managing items, drops and store keys
A store key, cd_iap_…Your server. Works on one store, limited to its scopes. Can't open stores or manage keys. GET /api/v2/iap/stores answers it with its own store, which is how a server reads its store's id from the key
NonePublic 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, and the MCP server from CHAINDADDY_IAP_KEY.

Store keys ​

A store key is how your server talks to your store. Give each server only the scopes it needs.

ScopeAllows
catalog:writeCreate and change items
orders:readRead orders
orders:writeCreate orders for a wallet
entitlements:readRead what wallets own
entitlements:writeGrant, consume and revoke items
drops:writeCreate and change drops
rewards:awardAward a wallet from an award drop
events:readRead the event feed
messages:sendMessage one of your customers
airdrops:awardPay token prizes from a prize pool that pinned this key. Not given by default
webhooks:writeSet, rotate and remove your store's webhook. Not given by default
bash
chaindaddy iap key create 3f0c… --name app-server --scope entitlements:read entitlements:write
chaindaddy iap key list 3f0c…
chaindaddy iap key revoke 3f0c… <keyId>

The secret (cd_iap_…) is shown once. Keep it in your secrets manager: a lost key can only be revoked and replaced. A revoked key stops working within a minute.

Rate limits ​

Store-key calls are limited per key:

BudgetLimit
Each store key2,400 requests a minute
All store keys from one IP address9,600 requests a minute
Unknown or revoked keys, per IP address60 requests a minute
Token prize awards60 a minute per key, within the key's budget

Responses carry X-RateLimit-Limit and X-RateLimit-Remaining. Past a limit the API answers 429 with Retry-After in seconds.

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. Build keys from something stable in your app (user-42-milestone-10), not a fresh random value per attempt. The CLI generates one when you don't pass --idempotency-key, and prints it with --json.

An order's key is bound to the request that first used it: the store, item, quantity, wallet, appAccountToken, ref and app. The same key with the same request returns the same order. The same key with anything else in it is refused with 422 IDEMPOTENCY_KEY_REUSED. Use a new key for a new purchase.

Token prizes are stricter: see Token Prizes.

Events and webhooks ​

Every change in your store is written to an event feed. The same events are sent as webhooks named iap.<type> (iap.order.confirmed, …).

These go to the store's owner. If you built a paid app that sells on other people's pages, your own sales reach you as app.order.confirmed: Paid apps: Webhooks.

EventWhenpayload beyond the basics
order.createdAn order was quoted
order.paidIts payment is on chain, not yet confirmed
order.confirmedItems granted, receipt issued. Deliver on this onereceipt
order.expiredIts deadline passed unpaid
order.revertedA paid order's payment was undone by a reorg; it is pending again
order.refundedThe store's owner recorded paying the buyer back
order.mismatchA payment named the order but not its quote; nothing granted
order.duplicate_paymentThe order was paid a second time; nothing more granted
order.checkout_paid_againThis order confirmed for a checkout session that another order had already completed: the checkout was paid twice. Both orders delivered. firstOrderId is the order that completed the session, sameWallet whether the same wallet paid both, payer and payoutAmount whom to send it back to and how much, if the buyer did not mean tofirstOrderId, firstWallet, sameWallet, payer, payoutAmount
entitlement.granted / .consumed / .revokedA wallet's items changedsku, delta, quantity after the change; on a spend an app made, refType: "app" and refId the app's id
entitlement.expiringA timed item ends within 72 hours (only items held longer than 72 hours)sku, name, expiresAt
entitlement.expiredA timed item has endedsku, name, expiredAt
drop.claimed / drop.awardedA wallet claimed a drop, or your server awarded one
drop.exhaustedA drop reached its capdropId, name
sku.sold_outA sale reached maxSupplysku, name, maxSupply
airdrop.award.submitted / .confirmed / .failedA token prize was sent, paid, or not paidawardId, campaignId, idempotencyKey, amount, status, txHash, error
store.not_live / store.liveThe store stopped selling because the opener's plan lapsed, or started again
bash
chaindaddy iap events 3f0c… --after 1200 --json
chaindaddy iap events 3f0c… --follow --json     # one event per line, as they happen

GET /api/v2/iap/stores/{id}/events?after= returns {entries, last}; pass last back as after to continue. Keep the highest eventId you have processed and read from it on start-up and on a timer.

Your store's webhook ​

Give your store a webhook and your server hears about every sale as it confirms. Set it in Developer portal → Store → Settings → Webhook (all events, or the ones you pick), or:

bash
chaindaddy iap webhook set 3f0c… --url https://app.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 redeliver 3f0c… --after 1200   # send events after 1200 again
chaindaddy iap webhook delete 3f0c…

Over HTTP: PUT /api/v2/iap/stores/{id}/webhook {url, events?}, GET, DELETE, POST …/webhook/test, POST …/webhook/redeliver {after, limit} and POST …/webhook/rotate-secret. Setting, rotating and removing need your account credentials or a store key with webhooks:write; a store key with events:read can read the webhook, send a test and send events again. The CLI's webhook set, rotate and delete always run as your account, so a server that holds only a store key makes those three changes over HTTP. The URL must be public https://. A webhook with picked events gets new event types only once you add them.

A confirmed purchase arrives like this:

json
{
  "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…",
    "livemode": true,
    "payload": {
      "sku": "credits.100",
      "quantity": 1,
      "status": "confirmed",
      "appAccountToken": "user-7731",
      "ref": null,
      "amount": "50000000000000000000",
      "txHash": "0x4b7e…",
      "payer": "0x5a1c…",
      "receiptId": "0d6f…",
      "receipt": "eyJhbGciOiJFZERTQSIs…"
    }
  }
}

appAccountToken is what you passed when you created the order. ref is the payment link's tag, or null. id is the same on every retry of one event, so use it to ignore repeats. livemode is false only for a test store's events.

Every delivery is signed the Standard Webhooks way (webhook-id, webhook-timestamp, webhook-signature), so a Standard Webhooks library checks it with your whsec_… secret as it is. Check the raw bytes before parsing. Webhooks § Check the signature has Python and Go. The older X-Webhook-Signature (hex HMAC of the body alone) is deprecated and is still sent until 2027-10-31. The other headers are X-Webhook-Event, X-Webhook-Delivery (the id) and X-Webhook-Timestamp.

A whole receiver you can run as it is, in Node with one package, is in Take payments on your site: A complete handler. For a store that sells without checkout sessions, deliver on every iap.order.confirmed, not only the ones that carry a checkoutSessionId.

Answer 2xx within 5 seconds. Failed deliveries are retried; a webhook failing for 72 hours turns off, and saving its URL again turns it back on and sends everything that waited. See When a delivery fails.

Notifications ​

You and your buyers are told what matters in their notifications on chaindaddy.io, by push, by text and by email, each where the recipient has it on.

You, as the store's owner, hear about each new sale, payment problems (a mismatched, duplicate or undone payment), sold-out items, finished drops, your store stopping or starting to sell, and a webhook that turned off.

Your buyers hear when a purchase confirms, when a refund is recorded, when they receive or lose an item, when a timed item ends within 72 hours, and about problems with their payment. The notice that a timed item is ending also goes by email, and its link opens the item ready to buy again.

Sale alerts. Every sale is a push. You can also have sales texted, at most one text every 15 minutes, to a phone number you have verified. Set it up in Sale alerts in your store's Settings. On an iPhone, add Chain Daddy to your Home Screen first: Safari only delivers push to an installed web app.

Everyone manages these at chaindaddy.io/_preferences: yours under Your Store, a buyer's under Purchases.

Messaging one user ​

You can write to one customer yourself: a thank-you, a note that their order is ready, a reminder that their membership ends soon. It arrives in the app on chaindaddy.io and as a push, signed "From your store on $YOURTOKEN". It does not go by text or email: a customer who turned on texts for apps gets only our "you have new updates" text, at most once a day.

From your server, to a wallet that bought from your store or was given an item by it, with a store key that has messages:send:

bash
curl -X POST https://api.chaindaddy.io/api/v2/iap/stores/$STORE_ID/messages \
  -H "Authorization: Bearer $CHAINDADDY_IAP_KEY" -H "Content-Type: application/json" \
  -d '{"wallet":"0x…","key":"renewal-2026-10","title":"Your pass ends Friday","body":"Renew any time from the app."}'
# {"status":"sent"}   or "duplicate" (that key was used for this user before) or "muted"

From an app on your coin page, to the signed-in user, with the notify action and the notify:user permission:

typescript
const res = await onAction({ type: 'notify', key: `order-${orderId}`, title: 'Your order is in', body: 'Open the app to use it.' });
// res.data.status: 'sent' | 'duplicate' | 'muted'
  • The title is one line of up to 60 characters; the body is plain text of up to 280.
  • key is your id for the message (letters, digits, ., _, :, -). The same key to the same user sends nothing and answers duplicate, so retries are safe.
  • At most 5 messages to one user in any 24 hours; past that the answer is 429.
  • Users can mute your app or store from any of its notifications. A muted send answers muted.

All sales are final ​

You are the seller of record: you set prices, deliver what you sell, and look after your buyers. All sales are final, except where the law gives the buyer a right that cannot be waived.

  • Making good yourself. If you choose to pay a buyer back from your own wallet, the store's owner can record it, which revokes the items and the receipt. Send it to the wallet the money came from (the order's payer, shown as Send it to on the order's page and as paid by in chaindaddy iap order show), and send what you received (payoutAmount): the store fee and any split went to other wallets and are not yours to return. It cannot be recorded on an item or license an app sold (409 FINAL_SALE):

    bash
    chaindaddy iap order refund 3f0c… 0x9e1f… --tx 0xRefundTxHash --note "double purchase"
  • Never refund the swap-pay contract. When a buyer pays with another coin in one transaction, Chain Daddy's swap-pay contract (ChainDaddySwapPay) pays the order for them, so a block explorer or the checkout contract's paidBy shows that contract as the payer. Anything sent to it goes to whoever pays through it next. Refund the order's payer from Chain Daddy's API, webhook or order page, which names the buyer. If payer is an empty string and the order page shows no refund address, Chain Daddy cannot confirm the buyer yet: don't refund.

  • A checkout paid twice (order.checkout_paid_again: see One buyer at a time). Both orders were delivered. The second order's page says who paid and how much to send back. Once you have recorded that refund, Chain Daddy returns its own fee on that second payment to the buyer on request.

  • A mismatched or duplicate payment (order.mismatch, order.duplicate_payment: see How a purchase settles) grants nothing. If the money reached you, settle it with the payer.

Tools for agents ​

An AI agent can run your store through the iap_* tools of the local MCP server, @chaindaddy/mcp: one tool per chaindaddy iap command. How to connect it, and where its keys go, is in Build with AI. The full request and response schemas are in the OpenAPI spec under the Creator Store tag.

  • Test: the whole loop against a test store, with no money
  • Actions: every action an app can send its coin page
  • Token Prizes: paying your token from a prize pool
  • Verifying tokens: checking a receipt on your server, in code