Token Prizes
Your server pays a winner an amount of your token: a contest winner, the top of a leaderboard, a bounty. You fund a prize pool from your own wallet and cap how far it can go. Your server gets a key that pays prizes from that pool. The winner signs nothing and pays no gas.
To give away store items instead (a badge, credits, a pass), use a drop: nobody pays and no tokens move.
How it works
- Create a prize pool on your coin 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.
- Approve it to spend from your wallet, up to the budget. Your tokens stay in your wallet until a prize is paid, then move straight to the winner.
- Create an award key: a store key with the
airdrops:awardandevents:readscopes. The pool accepts only this key, chosen by the wallet funding the pool. - Your server calls one endpoint for each prize, with the winner's wallet and the amount.
- Hear how it ended from the
iap.airdrop.award.*webhooks, or look it up by itsIdempotency-Key.
Prize rules
You sponsor every prize you pay: you fund it, publish its rules, pick the winners, and are responsible for offering it lawfully where you offer it.
- Don't charge an entry fee or stake toward a prize.
- When taking part needs a payment, a purchase or holding your token, the prize must be won mainly by skill or by taking part, not by chance. No raffles or lotteries.
- Nothing you or your app sells may have a chance-based outcome: no paid loot boxes, mystery boxes, raffles or lotteries.
The full rules are in the Terms.
Before you start
- A registered coin page for your token on the chain you want to pay on.
- Your token's store (who can open one). Store keys work only while the wallet that opened it keeps its plan.
- The prize tokens, in the wallet that funds the pool.
Set it up
From your coin page
- Coin 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. 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, because you won't see it again, and put it in your server's secrets.
- Pay any gas your monthly allowance doesn't 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
chaindaddy login # once: sign in through your browser
chaindaddy airdrop create --page FLUXGP --name "Weekly 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). list and get show your pools, and status turns one on, pauses or ends it.
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: round-42:place-1" \
-H "Content-Type: application/json" \
-d '{"wallet": "0x5a1c…", "amount": "500000000000000000000", "reason": "Round 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": "round-42:place-1",
"wallet": "0x5a1c…",
"amount": "500000000000000000000",
"reason": "Round 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 also work in place of the store key, if you manage the coin page and have the plan.
Take the winner's wallet from a sign-in you have verified, never from a value a 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 own wallet sign-in.
A wallet can't be the zero address, the pool's funding wallet, the distributor or the token itself, or on Solana 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 own ids (round-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, even if the pool has been paused or filled since. - 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 (
round-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, .confirmed or .failed (a webhook with picked events needs them picked), and written to the store's event feed as airdrop.award.*:
{
"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": "round-42:place-1",
"amount": "500000000000000000000",
"reason": "Round 42: 1st place",
"status": "confirmed",
"txHash": "0x4b7e…",
"error": null
}
}
}To ask instead, read GET …/airdrops/{poolId}/awards?idempotencyKey=round-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 before it is accepted.
| 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, within the store key's own budget. Past it you get 429 with Retry-After.
You can change the budget and the limits while the pool is paused.
Gas
Chain Daddy sends each prize and pays its gas from your monthly airdrop allowance. Anything beyond it you pay from your wallet before the pool goes live (a prize pool can't be paid for by card). 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. Resuming a paused pool 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 leaderboard
Each weekly round pays 500, 250 and 100 tokens to its top three. The token has 18 decimals.
// Your 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;
// winners: the top wallets, first place first, from verified sign-ins.
export async function payTopThree(roundId, winners) {
const awards = [];
for (const [i, wallet] of winners.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': `round-${roundId}:place-${place}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
wallet,
amount: (PRIZES[i] * UNIT).toString(),
reason: `Round ${roundId}: 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 round. Places already awarded come back with Idempotent-Replayed: true, and only the missing ones are paid. If the result changed in between, 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 the webhook-signature header (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); // "round-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=round-42:place-1" \
-H "Authorization: Bearer $CHAINDADDY_IAP_KEY"
# → {"entries":[{"idempotencyKey":"round-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 "Round 42: 1st place" \
--idempotency-key round-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 round-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