Skip to content

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 ​

  1. 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.
  2. 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.
  3. You create an award key. It is a store key with the airdrops:award and events:read scopes, and the pool accepts only this one key. Only you, the wallet funding the pool, can choose it.
  4. Your server calls one endpoint for each prize, with the winner's wallet and the amount. The prize is queued and sent within seconds.
  5. You hear how it ended from the iap.airdrop.award.* webhooks, or by asking for it by its Idempotency-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 ​

  1. Token Manager → Community → Airdrops → New Airdrop.
  2. Under Delivery, choose Your game server awards it.
  3. 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.
  4. Open it. Under Fund the distributor, approve the budget from your wallet.
  5. 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.
  6. 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 ​

bash
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> active

Every 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 ​

bash
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
walletThe winner. An EVM address, or a Solana wallet address (not a token account)
amountBase units of your token, as an integer string. With 18 decimals, 500 tokens is "500000000000000000000"
reasonOptional, up to 200 characters. Shown with the award and to the winner

Any other field is refused. The answer is 201:

json
{
  "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": "…"}.

StatuscodeWhat to do
400INVALID_REQUEST, IDEMPOTENCY_KEY_REQUIRED, IDEMPOTENCY_KEY_INVALID, INVALID_WALLET, INVALID_AMOUNTFix the request
401INVALID_STORE_KEYThe key is wrong or revoked
403SCOPE_REQUIREDThe key lacks airdrops:award
403IAP_PLAN_REQUIREDThe wallet that opened the store no longer has the plan
404NOT_FOUNDNo such pool for this key: wrong store or pool id, not a prize pool, or another key is pinned
409IDEMPOTENCY_KEY_REUSEDThis key already made a different award; existingAwardId names it
409CAMPAIGN_NOT_ACTIVEThe pool is a draft, paused, finished, or outside its start and end
409AWARD_OVER_MAX, BUDGET_EXHAUSTED, MAX_AWARDS_REACHED, WALLET_LIMIT, DAILY_LIMITA limit would be passed. Nothing was awarded
429rate_limit_exceededWait Retry-After seconds, then retry with the same key
503IAP_DISABLED, AIRDROPS_DISABLEDSwitched 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 200 with Idempotent-Replayed: true and 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, with existingAwardId. 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.

StatusMeans
pendingAccepted and queued. Its amount is set aside from the budget
submittedThe transfer is on chain, waiting for confirmation. txHash is set
confirmedPaid: the winner has the tokens
failedNot 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:

json
{
  "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.

LimitSet withRefused with
The most one award may payMax per award (awardMaxAmount)AWARD_OVER_MAX
The pool's totalTotal budgetBUDGET_EXHAUSTED
The number of awardsMax awards (maxClaims). Failed awards don't countMAX_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 overWALLET_LIMIT
What the pool may pay in any 24 hoursMost paid in any 24 hours (dailyMaxAmount)DAILY_LIMIT
When it runsStatus, and the optional start and endCAMPAIGN_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 ​

ToDoEffect
Stop new prizes for nowPauseNew awards get 409 CAMPAIGN_NOT_ACTIVE. Queued prizes wait; ones already sent finish. Resume to carry on
End itCancelFinal. Prizes not yet sent are not paid: each is marked failed and fires iap.airdrop.award.failed
Cut off the keyRevoke it in Developer portal → Store → KeysAwards with it are refused at once
Swap the keyReplace the key on the pool, move your server to the new one, then revoke the old oneThe pool accepts only the newest key from the moment you replace it
Take back the spending approvalSet 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.

js
// 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:

js
// 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:

bash
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:

bash
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 --json