Token Prizes
Your game server pays a winner an amount of your token: first place in a race, a tournament payout, a bounty. You fund a prize pool from your own wallet and set how far it can go. Your server gets a key that can pay prizes from that pool and nothing else. The winner signs nothing and pays no gas.
A token prize moves tokens. To give away store items instead (a badge, credits, a pass), use a drop: nobody pays and no tokens move.
How it works
- You create a prize pool on your token page: an airdrop whose delivery is Your game server awards it. It has a budget, a max per award, a max number of awards and optional limits.
- You approve it to spend from your wallet, up to the budget. Your tokens stay in your wallet: each prize moves from your wallet straight to the winner when it is paid. Chain Daddy never holds them.
- You create an award key. It is a store key with the
airdrops:awardandevents:readscopes, and the pool accepts only this one key. Only you, the wallet funding the pool, can choose it. - Your server calls one endpoint for each prize, with the winner's wallet and the amount. The prize is queued and sent within seconds.
- You hear how it ended from the
iap.airdrop.award.*webhooks, or by asking for it by itsIdempotency-Key.
Before you start
- A registered token page for your token on the chain you want to pay on.
- Your token's store, opened by a wallet with a Developer plan or Developer Beta Program membership. Store keys only work while that wallet keeps it (who can open a store).
- The prize tokens, in the wallet that funds the pool.
Set it up
From your token page
- Token Manager → Community → Airdrops → New Airdrop.
- Under Delivery, choose Your game server awards it.
- Set the Total budget, the Max per award and, if you want them, Max awards and the limits (below). Amounts are in whole tokens. Save the draft.
- Open it. Under Fund the distributor, approve the budget from your wallet.
- Under Your game server, click Create award key. If your token has no store yet, Open your store first. Copy the key when it is shown: you won't see it again. Put it in your server's secrets.
- Pay any gas the allowance does not cover, then Activate.
The pool's page lists every prize your server has awarded, and finds one by its Idempotency-Key.
From the CLI
npm install -g @chaindaddy/cli@beta
export CHAINDADDY_API_KEY=cd_live_… # your account key
chaindaddy airdrop create --chain base --crown 8 --name "Race prizes" --mode award \
--budget-tokens 100000 --max-award-tokens 500 --max-awards 1000 \
--wallet-max-count 3 --wallet-window 1d --daily-max-tokens 5000 --json
chaindaddy airdrop fund <campaignId> # your allowance, and the exact approve to run
chaindaddy airdrop key create --campaign <campaignId>
# creates a key with airdrops:award and events:read, pins it on the pool,
# and prints the cd_iap_… secret ONCE: put it in your server's secrets
chaindaddy airdrop status <campaignId> activeEvery amount option also has a base-unit form (--budget, --max-award, --wallet-max-amount, --daily-max); the -tokens forms take whole tokens and decimals. list and get show your pools, status turns one on, pauses or ends it, and every command takes --json.
Paying a prize
curl -X POST https://api.chaindaddy.io/api/v2/iap/stores/$STORE_ID/airdrops/$POOL_ID/award \
-H "Authorization: Bearer $CHAINDADDY_IAP_KEY" \
-H "Idempotency-Key: race-42:place-1" \
-H "Content-Type: application/json" \
-d '{"wallet": "0x5a1c…", "amount": "500000000000000000000", "reason": "Race 42: 1st place"}'| Field | |
|---|---|
wallet | The winner. An EVM address, or a Solana wallet address (not a token account) |
amount | Base units of your token, as an integer string. With 18 decimals, 500 tokens is "500000000000000000000" |
reason | Optional, up to 200 characters. Shown with the award and to the winner |
Any other field is refused. The answer is 201:
{
"award": {
"id": "7c1e…",
"campaignId": "0b9a…",
"idempotencyKey": "race-42:place-1",
"wallet": "0x5a1c…",
"amount": "500000000000000000000",
"reason": "Race 42: 1st place",
"status": "pending",
"txHash": null,
"error": null,
"chainKey": "base",
"tokenAddress": "0x931f…",
"tokenDecimals": 18,
"createdAt": "2026-10-02T18:04:11Z",
"updatedAt": "2026-10-02T18:04:11Z"
},
"campaign": { "status": "active", "remainingBudget": "99500000000000000000000", "awardsLeft": 999 }
}Your account credentials work in place of the store key, as long as you manage the token page and have the plan. That is what the CLI uses when you run chaindaddy airdrop award without a store key.
Where the winner's wallet comes from. Take it from a sign-in you have verified, never from a value a game client sends you. In a Crown App, that is the app's session token: check typ, aud and exp, and use its sub. Otherwise use your game's own wallet sign-in.
Amounts must be positive and a wallet must be a real wallet: not the zero address, not the pool's funding wallet, the distributor or the token itself, and on Solana not a token account or a program address.
Errors
Errors are {"error": "…", "code": "…"}.
| Status | code | What to do |
|---|---|---|
| 400 | INVALID_REQUEST, IDEMPOTENCY_KEY_REQUIRED, IDEMPOTENCY_KEY_INVALID, INVALID_WALLET, INVALID_AMOUNT | Fix the request |
| 401 | INVALID_STORE_KEY | The key is wrong or revoked |
| 403 | SCOPE_REQUIRED | The key lacks airdrops:award |
| 403 | IAP_PLAN_REQUIRED | The wallet that opened the store no longer has the plan |
| 404 | NOT_FOUND | No such pool for this key: wrong store or pool id, not a prize pool, or another key is pinned |
| 409 | IDEMPOTENCY_KEY_REUSED | This key already made a different award; existingAwardId names it |
| 409 | CAMPAIGN_NOT_ACTIVE | The pool is a draft, paused, finished, or outside its start and end |
| 409 | AWARD_OVER_MAX, BUDGET_EXHAUSTED, MAX_AWARDS_REACHED, WALLET_LIMIT, DAILY_LIMIT | A limit would be passed. Nothing was awarded |
| 429 | rate_limit_exceeded | Wait Retry-After seconds, then retry with the same key |
| 503 | IAP_DISABLED, AIRDROPS_DISABLED | Switched off for now. Retry later with the same key |
Retries
Idempotency-Key is required. Give every prize its own key, built from your game's own ids (race-42:place-1), not a random value per attempt. Keys are scoped to the pool.
- Same key, same body: you get
200withIdempotent-Replayed: trueand the award as it is now. Nothing is paid twice. This holds even if the pool has been paused or filled since: the repeat is recognised before any limit is checked. - Same key, different body: a different wallet, amount or reason is
409 IDEMPOTENCY_KEY_REUSED, withexistingAwardId. A key names one prize. - After a failed award: the key stays with it. To try again, send a new key (
race-42:place-1:retry-1).
So after a timeout or a crash, send the same request again. You get the first result back.
What happens next
An award goes pending → submitted → confirmed, or ends failed.
| Status | Means |
|---|---|
pending | Accepted and queued. Its amount is set aside from the budget |
submitted | The transfer is on chain, waiting for confirmation. txHash is set |
confirmed | Paid: the winner has the tokens |
failed | Not paid. Its amount is back in the budget, and error says why |
Each change is sent to your store's webhook as iap.airdrop.award.submitted, iap.airdrop.award.confirmed or iap.airdrop.award.failed, and written to the store's event feed as airdrop.award.*. A webhook set to All events receives them; one set to chosen events needs them picked. The payload:
{
"id": "iap_evt_1311",
"type": "iap.airdrop.award.confirmed",
"created_at": "2026-10-02T18:04:19Z",
"data": {
"eventId": 1311,
"type": "airdrop.award.confirmed",
"storeId": "3f0c…",
"chainId": "eip155:8453",
"crownId": 8,
"wallet": "0x5a1c…",
"orderId": null,
"payload": {
"awardId": "7c1e…",
"campaignId": "0b9a…",
"idempotencyKey": "race-42:place-1",
"amount": "500000000000000000000",
"reason": "Race 42: 1st place",
"status": "confirmed",
"txHash": "0x4b7e…",
"error": null
}
}
}Verify the signature as in Your store's webhook. To ask instead, read GET …/airdrops/{poolId}/awards?idempotencyKey=race-42:place-1 (it returns {entries, nextCursor}), or GET …/airdrops/{poolId}/awards/{awardId}. Both take the award key.
The winner is notified when their prize is paid. You are notified when one fails.
Limits
Every award is checked against all of these at once, before it is accepted. Two awards arriving together cannot both squeeze under a limit.
| Limit | Set with | Refused with |
|---|---|---|
| The most one award may pay | Max per award (awardMaxAmount) | AWARD_OVER_MAX |
| The pool's total | Total budget | BUDGET_EXHAUSTED |
| The number of awards | Max awards (maxClaims). Failed awards don't count | MAX_AWARDS_REACHED |
| What one wallet may win: a number of awards, a total, or both, over a window (an hour, a day, a week, 30 days, or the whole pool) | Awards per wallet, Total per wallet, Per-wallet limits count over | WALLET_LIMIT |
| What the pool may pay in any 24 hours | Most paid in any 24 hours (dailyMaxAmount) | DAILY_LIMIT |
| When it runs | Status, and the optional start and end | CAMPAIGN_NOT_ACTIVE |
Set the 24-hour limit even if you trust your server: it caps what anyone holding a leaked key could take in a day.
Each key may send 60 awards a minute, on top of the store key's own budget. Past it you get 429 with Retry-After.
Gas. Chain Daddy sends each prize and pays its gas from your monthly airdrop allowance; anything beyond it you pay before the pool goes live. The pool is priced for its max awards, so set that to what you expect to pay out. On Solana every prize is priced as if the winner needs a new token account.
You can change the budget and the limits while the pool is paused. Resuming prices the gas again.
Stopping it
| To | Do | Effect |
|---|---|---|
| Stop new prizes for now | Pause | New awards get 409 CAMPAIGN_NOT_ACTIVE. Queued prizes wait; ones already sent finish. Resume to carry on |
| End it | Cancel | Final. Prizes not yet sent are not paid: each is marked failed and fires iap.airdrop.award.failed |
| Cut off the key | Revoke it in Developer portal → Store → Keys | Awards with it are refused at once |
| Swap the key | Replace the key on the pool, move your server to the new one, then revoke the old one | The pool accepts only the newest key from the moment you replace it |
| Take back the spending approval | Set the allowance to 0 (EVM approve(distributor, 0)), or revoke the delegate (Solana spl-token revoke) | The next prize fails for lack of funds and the pool pauses itself |
One approval covers every airdrop on that token
On EVM, the approval you give the distributor is one allowance for the token, shared by every airdrop you fund with it from that wallet. Each pool only spends its own budget, but setting the allowance to 0 stops all of them. On Solana a token account has one delegate, with the same effect.
If your key leaks: revoke it, pause the pool, then create a new key. Set the allowance to 0 as well if you are not sure what else has it.
Example: pay the top 3 of a race
A racing game pays 500, 250 and 100 tokens to the first three finishers of every race. The token has 18 decimals.
// Your game server. CHAINDADDY_IAP_KEY is the pool's award key (cd_iap_…).
const API = 'https://api.chaindaddy.io';
const STORE_ID = process.env.CHAINDADDY_STORE_ID;
const POOL_ID = process.env.CHAINDADDY_PRIZE_POOL_ID;
const PRIZES = [500n, 250n, 100n]; // whole tokens, 1st to 3rd
const UNIT = 10n ** 18n;
// finishers: the winners' wallets, fastest first, from verified sign-ins.
export async function payTopThree(raceId, finishers) {
const awards = [];
for (const [i, wallet] of finishers.slice(0, PRIZES.length).entries()) {
const place = i + 1;
const res = await fetch(`${API}/api/v2/iap/stores/${STORE_ID}/airdrops/${POOL_ID}/award`, {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.CHAINDADDY_IAP_KEY}`,
'Idempotency-Key': `race-${raceId}:place-${place}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
wallet,
amount: (PRIZES[i] * UNIT).toString(),
reason: `Race ${raceId}: place ${place}`,
}),
});
const body = await res.json();
if (!res.ok) {
// A limit (DAILY_LIMIT, BUDGET_EXHAUSTED…) or a paused pool. Nothing was paid for this place.
throw new Error(`place ${place}: ${body.code} ${body.error}`);
}
awards.push(body.award); // status "pending"
}
return awards;
}If the server dies halfway, run payTopThree again for the same race. Places already awarded come back with Idempotent-Replayed: true, and only the missing ones are paid. If the result changed in between (a finisher was disqualified), the same place with a different wallet is refused with 409 IDEMPOTENCY_KEY_REUSED rather than paid twice.
Mark each place paid when its webhook arrives:
// After verifying X-Webhook-Signature (see Your store's webhook).
const event = JSON.parse(rawBody);
const { idempotencyKey, txHash, error } = event.data.payload ?? {};
if (event.type === 'iap.airdrop.award.confirmed') {
await markPrizePaid(idempotencyKey, txHash); // "race-42:place-1"
} else if (event.type === 'iap.airdrop.award.failed') {
await markPrizeFailed(idempotencyKey, error); // retry, if you want to, with a new key
}Or, without a webhook, ask for one place by its key until it has settled:
curl "https://api.chaindaddy.io/api/v2/iap/stores/$STORE_ID/airdrops/$POOL_ID/awards?idempotencyKey=race-42:place-1" \
-H "Authorization: Bearer $CHAINDADDY_IAP_KEY"
# → {"entries":[{"idempotencyKey":"race-42:place-1","status":"confirmed","txHash":"0x4b7e…",…}],"nextCursor":null}The same from the CLI:
export CHAINDADDY_IAP_KEY=cd_iap_…
# Pay 1st place and wait until it is paid or has failed
chaindaddy airdrop award $POOL_ID --store $STORE_ID --wallet 0x5a1c… \
--amount-tokens 500 --reason "Race 42: 1st place" \
--idempotency-key race-42:place-1 --wait --json
# One place by its key, one winner's awards, or the ones that failed
chaindaddy airdrop awards $POOL_ID --store $STORE_ID --idempotency-key race-42:place-1 --json
chaindaddy airdrop awards $POOL_ID --store $STORE_ID --wallet 0x5a1c… --json
chaindaddy airdrop awards $POOL_ID --store $STORE_ID --status failed --jsonRelated docs
- Store and Rewards: store keys, the event feed and your store's webhook
- Airdrops: approving the distributor and who pays the gas
- Webhooks: every
iap.*event