Skip to content

Actions ​

An app on a coin page asks the page to do things for it with onAction(action): sign the visitor in, open a purchase sheet, propose a transaction, call the platform API. The page (the host) checks the request against your app's approved manifest, does the work on its own UI, and answers.

This page is the contract. For each action it gives the request, the answer, every refusal and its message, the manifest permission it needs, and whether the visitor has to tap. A game that is not a React widget sends the same actions through window.chaindaddy.action (Talk to the page). A runner's server-to-server calls are in Runners and events.

How an action is answered ​

Every app that is not first-party runs in an isolated frame, and onAction returns a Promise there. It settles in one of four ways:

OutcomeMeaning
Resolves { ok: true, data }Done. data is the action's answer.
Resolves { ok: false, error }The page handled the request and the answer is no: it failed a check, the visitor declined, or the API refused. error says why. A failed api-call also carries status (see below). A type the page does not know answers This page has no "{type}" action.
Rejects with an ErrorThe page refused before handling it: your manifest lacks the permission, or an unreviewed build asked for something it may not. err.message says why.
Resolves undefinedA fire-and-forget action (navigate, clipboard, track, toast, connect-wallet, open-settings).

So wrap the call once and treat the two kinds of refusal the same way:

typescript
async function ask(action: AppWidgetAction): Promise<{ ok: boolean; data?: unknown; error?: string }> {
  try {
    return (await onAction(action)) ?? { ok: true };
  } catch (err) {
    return { ok: false, error: err instanceof Error ? err.message : String(err) };
  }
}

Terms used below:

  • Unreviewed build. A build the platform has not reviewed: your own bundle while you develop, or the preview you see on your own coin page. It may read but not write through api-call, and it may not ask for signatures or transactions, or spend a visitor's items.
  • One wallet request at a time. A transaction, a signature and a purchase each hold the visitor's wallet. While one is open, the page answers another with a refusal rather than queueing it.

At a glance ​

ActionPermissionThe visitor's tapUnreviewed buildSuccess data
navigatenavigate:internalrequiredallowednone (undefined)
clipboardclipboard:writerequiredallowednone
tracknonenoallowednone
toastnonenoallowednone
modalnonenoallowed{ button }
api-callnone (a drop claim needs iap:purchase)noreads onlythe endpoint's JSON
connect-walletnonerequiredallowednone
sign-innonerequiredallowed{ signedIn: true }
sign-typed-datawallet:signnorefusedthe signature
sign-messagewallet:signnorefused{ signature, message, publicKey }
transactionwallet:transactionnorefused{ txHash, chainId }
quotewallet:tradenoallowed{ status }
iap-purchaseiap:purchasenoallowed{ status, orderId, receipt }
iap-consumeiap:consumerequiredrefused{ sku, remaining }
paid-filenonenoyour own files, with no purchase{ url, expiresAt, sku }
payiap:paynoallowed{ status, orderId, receipt }
notifynotify:usernoallowed{ status }
session-tokennet:connectnoallowed{ token, expiresAt }
upload-mediafetch:self-apirequiredallowed{ url, cid, contentType }
fullscreenui:fullscreensend it from a tap in your appallowednone ({ ok: true })
open-settingsnonenoallowednone
audiononenoallowednone ({ ok: true })

"No" in the tap column means the page does not check for one. Every wallet request and purchase still waits for the visitor to confirm on the page's own sheet.

Moves the visitor to another page of the site, in the same tab.

typescript
onAction({ type: 'navigate', path: '/doge/arbitrum' }); // token pages live at /{symbol}/{chain}
  • Answer: resolves undefined.
  • Refused: resolves { ok: false, error: 'This app is not approved to move you to another page' } without navigate:internal, and { ok: false, error: 'Moving to another page needs a tap in the app' } unless the visitor just tapped or typed in your app.
  • Refused silently: a path that does not start with /, starts with /_ or /admin, or contains ://. The page logs a warning in its own console and does nothing.
  • Fullscreen: ends it first.

clipboard ​

typescript
onAction({ type: 'clipboard', text: 'DOGE' });
  • Answer: resolves undefined, whether or not the write worked.
  • Refused: resolves { ok: false, error: 'This app is not approved to write to the clipboard' } without clipboard:write, and { ok: false, error: 'Copying needs a tap in the app' } unless the visitor just tapped or typed in your app (the page asks the browser, so a tap your app reports itself does not count).
  • Shown: the page shows a toast with your app's name and what was copied (a long value by its two ends), so the visitor always sees what went to their clipboard. The page does not tell your app when the browser refuses.

track ​

Counts a click in this coin page's link analytics.

typescript
onAction({ type: 'track', event: 'website', data: { from: 'menu' } });
  • Answer: resolves undefined.
  • event is kept when it names one of the page's link types (website, twitter, telegram, discord, github, whitepaper, email, ens); any other name is counted as custom. data, when sent, is kept as its JSON text.

toast ​

Shows a short notice on the page. It answers nothing.

typescript
onAction({ type: 'toast', message: 'Saved', variant: 'success' }); // variant: 'success' | 'error' | 'info'

The page draws it as plain text on one line, cut at 140 characters, labelled with your app's name, for 4 seconds. One toast shows at a time: a new one replaces the one showing. A toast sent while your app is scrolled out of view, or while the tab is hidden, is dropped.

connect-wallet ​

Opens the wallet's Connect view.

typescript
onAction({ type: 'connect-wallet' });
  • Answer: resolves undefined at once, before the visitor connects. Watch connectedAddress in your props.
  • Refused: resolves { ok: false, error: 'Connecting a wallet needs a tap in the app' } unless the visitor just tapped or typed in your app, so send it from their tap on your Connect button.
  • It asks nothing of a wallet that is already connected, and it does not sign the visitor in. To do both, send sign-in.
  • Fullscreen: ends it first.

open-settings ​

Opens your app's settings on the page, for a visitor who can edit the page (its owner or a manager).

typescript
onAction({ type: 'open-settings' });
  • Answer: resolves undefined. For any other visitor nothing happens.

audio ​

Keeps a game's sound playing in a hidden tab. A game's frame (an app filed under games, or an engine app) silences and suspends its audio while the visitor's tab is hidden; a game that should play on, such as a radio or a timer, says so once.

typescript
onAction({ type: 'audio', background: true });
  • Answer: resolves { ok: true }, from the frame. background: false goes back to the default. The visitor's own mute and volume still apply.

Dialogs ​

Shows a dialog the page draws, with your plain text: no HTML and no links. Its first line always says which app it came from.

typescript
const res = await onAction({
  type: 'modal',
  title: 'Join the holder call?',                          // 1–80 characters
  body: 'It starts Sunday at 18:00 UTC.',                  // optional, up to 600
  buttons: [                                                // optional, 1–3; default one "OK" button with id "ok"
    { id: 'no', label: 'Not now' },                         // id: 1–32 letters, digits, - or _
    { id: 'go', label: 'Join', style: 'primary' },          // label: 1–24 characters
  ],
});
// { ok: true, data: { button: 'go' } }   the pressed button's id, or null if the visitor dismissed it

Title, body and labels are trimmed. A style other than primary is drawn as secondary.

errorWhen
A dialog needs a titletitle is missing or blank
A dialog title is at most 80 characters
A dialog body is textbody is not a string
A dialog body is at most 600 characters
A dialog has 1 to 3 buttonsbuttons is empty, not a list, or longer than 3
A dialog button id is 1–32 letters, digits, - or _
A dialog button label is 1–24 characters
Dialog button ids must differ
A dialog or wallet request is already openanother dialog, transaction or signature is showing

No permission is needed, and a dialog does not end fullscreen: it draws over your app. modal with a modal name instead of a title opens one of the page's own dialogs, and is for first-party apps only.

Calling the platform API ​

api-call ​

Sends a request to the platform API as the signed-in visitor (their session goes with it when they are signed in). Your app never sees the session.

typescript
const res = await onAction({
  type: 'api-call',
  method: 'GET',                                            // GET, POST or PUT; default GET
  endpoint: `/api/v2/registration/${appData.tokenId}/polls?chain=${appData.chainKey}`,
  body: { },                                                // sent with anything but GET
});
// { ok: true, data }   data is the endpoint's JSON response (undefined for a 204)

What it reaches. A request goes through only if it matches one of these:

MethodPathNotes
GET/api/v2/registration/{id}/…{id} must be this page's token id (appData.tokenId). A ?chain= must be this page's chain (appData.chainKey or appData.chainId): token ids repeat across chains.
GET/api/v2/apps/…, /api/v2/token/…, /api/v2/search…, /api/v2/broadcasts/…, /api/v2/polls/…, /api/v2/iap/public/…, /api/v2/iap/me/…, /api/v2/iap/orders/…Public data, and the visitor's own store items and orders.
PUT/api/v2/registration/{id}/apps/{app}/config?chain={chain}Your app's own config on this coin page (see App config). {id} and ?chain= as above, {app} your app's id (widgetId in your props).
POST/api/v2/iap/drops/{slug}/claimClaiming a reward drop for the visitor. Needs iap:purchase.
POST/api/v2/polls/{pollId}/voteA vote the visitor signed. See Poll votes.

What the page adds. With any method but GET, the page sets three fields in your body from the page, over any you sent: creatorCrownId (this token id), tokenAddress and chainId (this page's chain, as a number).

OutcomeerrorWhen
resolvesEndpoint not allowed for widgetsthe path is on no list above, or holds .., %2e or a backslash
resolvesThis app can only reach this token's own data/api/v2/registration/{id}/… names another token, or ?chain= another chain
resolvesThis app can only read this, not change itany other write (a DELETE included) to a path it may read
resolvesAn unreviewed app can only read, not change anythingany method but GET from an unreviewed build
resolvesThis app is not approved to use the storea drop claim from an app without iap:purchase
resolvesthe API's own error text, or HTTP {status}the API refused the request

A failed call also carries status: the API's HTTP status when it answered (401 means the visitor is signed out), or 0 when the page refused the call before sending it or the network failed. Check status rather than matching the error text:

typescript
if (!res.ok && res.status === 401) askToSignIn();

Signing the visitor in ​

sign-in ​

Signs the visitor in to Chain Daddy: connects a wallet if none is, then asks that wallet to sign one message. This is the visitor's one sign-in; don't ask their wallet to sign a login of your own as well. The wallet is the one on your coin's chain: on a Solana coin, the visitor's Solana wallet, also when they are signed in with another.

typescript
button.onclick = async () => {
  const res = await onAction({ type: 'sign-in' });
  // { ok: true, data: { signedIn: true } }
  // { ok: false, error: 'Not signed in', data: { signedIn: false } }
};
  • Tap required. Send it from the visitor's tap, within 5 seconds of it. The page checks with the browser's own record of the tap where it has one. Sent without a tap, it resolves { ok: false, error: 'Sign-in needs a tap in the app' }.
  • Answer: once the visitor is signed in (at once if they already are). Not signed in covers a declined signature, a failed sign-in, and 2 minutes with no answer. A second request while one is open shares its answer.
  • Your signedIn prop says whether the visitor is signed in now, and changes when they sign in or out anywhere on the page. Key your signed-in state on it, not on connectedAddress, which stays the same across a sign-out.
  • Fullscreen: ends it for the wallet; send fullscreen again once you have the answer to take it back (see fullscreen).

session-token ​

A five-minute token for your own server, saying the signed-in visitor is using your app on this coin page. Needs net:connect with at least one origin in network.origins.

typescript
const res = await onAction({ type: 'session-token' });
// { ok: true, data: { token: 'eyJ…', expiresAt: 1790000300 } }   expiresAt: Unix seconds
errorWhen
This app is not approved to connect to a serverthe manifest lacks net:connect
Sign in to continuethe visitor is not signed in: send sign-in
Could not start a sessionanything else, including an approved manifest with no network.origins

All three resolve rather than reject. What the token says and how your server checks it: Session tokens and Verifying tokens.

Wallet requests ​

sign-typed-data ​

Asks the visitor to sign EIP-712 typed data. Needs wallet:sign: without it the page refuses the request (This app is not approved to ask for signatures). An unreviewed build can never ask.

typescript
const res = await onAction({
  type: 'sign-typed-data',
  // A message for your own server: its domain names your app id and a chain your app uses.
  domain: { name: 'guestbook', version: '1', chainId: Number(appData.chainId) },
  types: { Entry: [{ name: 'note', type: 'string' }, { name: 'signer', type: 'address' }] },
  primaryType: 'Entry',
  message: { note: 'gm', signer: '0x…' },
});
// { ok: true, data: '0x…' }   the signature

Before the visitor sees anything, the page checks what the signature is for, because a signature can hand over tokens with no further prompt:

  • A message for your app itself (no verifyingContract): domain.name must be your app id (or its slug, the part after the /) or your app's listing name exactly, and domain.chainId your token's chain or one in the manifest's wallet.chains. Nothing else accepts a signature in your app's name.
  • A message for a contract (verifyingContract): the contract must be one the manifest declares in wallet.contracts, on domain.chainId.
  • Never a grant: a permit or Permit2 allowance, an EIP-3009 transfer, a Safe transaction, an ERC-2771 forwarded or meta-transaction, a trade order, or a delegation, even to a contract the manifest declares. An app moves value only through the checkout (iap-purchase and pay).
  • Never: a signature over a hash the visitor cannot read (ERC-7739, Safe and smart-wallet messages), an ERC-4337 user operation or other signed transaction (propose a transaction instead), and Chain Daddy's own signatures, except the poll vote. A domain.name with "Chain Daddy" or "OpenCrown" anywhere in it counts as ours.

The page then shows its own sheet: your app's name, what the message does in plain words, its amounts and who receives them, what Chain Daddy checked and what it cannot, and every field. The visitor continues there, then confirms in their wallet. A request the page blocks for what it is (not for how it is written) also tells the visitor, and your app may not ask their wallet for anything else on that page.

The domain fields the page passes on are name, version and salt (strings), chainId and verifyingContract; any other domain field is dropped. types need not list EIP712Domain, and one you send is dropped: the wallet signs the domain the page checked. Values are passed on in one form, so an address must be a 0x address and an integer a number, bigint, or decimal or 0x string within its type's range.

OutcomeerrorWhen
rejectsAn unreviewed app cannot ask for signaturesan unreviewed build
resolvesAnother wallet request is already opena transaction, signature or purchase is open
resolvesThis signature request is incompletedomain, types or message is not an object, a type is missing or malformed, or primaryType is missing, over 64 characters, EIP712Domain or not in types
resolvesThis signature request's <field> is not a valid <type>, This signature request is missing <field>a value does not match its type
resolvesThis signature request names a contract that is not an address
resolvesThis signature request names a network that does not existchainId is not a positive whole number
resolvesApps may not ask for a signature that gives access to your tokensa grant (a permit, a transfer, a Safe or forwarded transaction, an order or a delegation) to a contract the manifest declares
resolvesThis app may not ask for <a grant> for a contract it did not declarea grant's contract is not in wallet.contracts
resolvesThis app is not approved to ask for signatures for that contractverifyingContract is not in wallet.contracts
resolvesAn app may ask for signatures only in its own name (domain.name is its app id or listing name) or for a contract it declares
resolvesThis app may ask for signatures only on its own networks (domain.chainId)
resolvesThis app may not ask for a signature over a hidden message, … for a signed transaction
resolvesApps may not ask for Chain Daddy's own signatures
resolvesThis app was stopped from using your wallet on this pagethe page blocked an earlier request from your app
resolvesSigning canceledthe visitor closed the sheet, or said no in their wallet
resolvesthe wallet's own message (up to 200 characters)the wallet failed, and the visitor then closed the sheet

sign-message ​

On a Solana coin, asks the visitor's Solana wallet to sign a message. Needs wallet:sign, like sign-typed-data, which is the action for an EVM coin. An unreviewed build can never ask.

Your app sends one sentence and a nonce. Chain Daddy writes the rest of the message, so it cannot pass for another service's sign-in:

typescript
const res = await onAction({
  type: 'sign-message',
  statement: 'Link this wallet to your Arena profile.',
  nonce: 'n-5f2c9a71b3', // from your server, used once
});
// { ok: true, data: { signature, message, publicKey } }

The wallet signs exactly this text, which comes back as message:

text
Chain Daddy app message v1
App: Arena (dev123/arena)
Coin: $BONK on Solana (DezXAZ8z7PnrnRJjz3wXBoRgixCa6xjnB7YaB1pPB263)
Site: chaindaddy.io
Wallet: 7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU
Nonce: n-5f2c9a71b3
Time: 2026-10-13T16:00:00Z

Link this wallet to your Arena profile.
  • statement: 1 to 280 characters on one line, plain text, with no space at either end. Line breaks and hidden or direction-changing characters are refused.
  • nonce: 8 to 64 characters of A-Z a-z 0-9 _ -.
  • signature is the Ed25519 signature in base58, publicKey the wallet's address. Send all three to your server and check them there.

The page shows its own sheet first: your app's name, your sentence and the whole text. The visitor continues there, then confirms in their wallet.

OutcomeerrorWhen
rejectsAn unreviewed app cannot ask for signaturesan unreviewed build
rejectsThis app is not approved to ask for signaturesthe manifest lacks wallet:sign
resolvessign-message works only on a Solana cointhe coin is on an EVM chain: use sign-typed-data
resolvesA message needs a statement and a nonceeither is missing or not a string
resolvesThe statement must be 1 to 280 characters, with no space at either end
resolvesThe statement must be one line of plain texta line break, or a hidden or direction-changing character
resolvesThe nonce must be 8 to 64 letters, digits, "_" or "-"
resolvesAnother wallet request is already opena transaction, signature or purchase is open
resolvesThis wallet can't sign messages for apps. Try a different Solana wallet.the wallet signed something other than the text (some hardware wallets do)
resolvesSigning canceledthe visitor closed the sheet, or said no in their wallet
resolvesThis page has no "sign-message" actionthe page does not offer it yet

transaction ​

Proposes one EVM transaction. Needs wallet:transaction, and an unreviewed build can never ask.

typescript
const res = await onAction({
  type: 'transaction',
  chainId: 8453,                          // number, decimal string, 0x string or eip155:8453
  to: '0x…',                              // a contract you declared
  data: '0x…',                            // the call
  value: '0',                             // optional: wei, decimal or 0x string; anything above 0 is refused
  description: 'Mint your member badge',  // optional, up to 200 characters, shown as "The app says: …"
});
// { ok: true, data: { txHash: '0x…', chainId: 8453 } }

Before the visitor sees anything, the page checks the call against your approved manifest's wallet scope:

  • Chain. Your token's own EVM chain, or one listed in wallet.chains.
  • Target. A contract listed in wallet.contracts, calling one of the methods you declared for it, in a way that moves no value.
  • No value moves. An app moves value only through the checkout (iap-purchase and pay), so a transaction may not send the chain's coin (value must be 0), move tokens (transfer, transferFrom, and NFT safe transfers), or call the Chain Daddy checkout contract.
  • Approvals. approve, increaseAllowance, permit (and DAI's), Permit2 approve and setApprovalForAll are refused. Revoking one (an amount of 0, or false) is allowed, with one exception: approve(spender, 0) counts as a revoke only on the coin's own token. On any other contract the same call can hand over token #0 of an NFT collection, so it is treated as an approval.

A call that passes opens the page's confirm sheet: the network, the contract and your label for it, the function and its arguments decoded (token amounts in the token's own units, an unlimited approval called out), what it sends, your description and your app's name. The visitor confirms, and their wallet asks once (a second time only if it has to switch network). The answer comes when the visitor closes the sheet; txHash is the transaction as sent, not yet confirmed.

OutcomeerrorWhen
rejectsAn unreviewed app cannot ask for transactionsan unreviewed build
rejectsThis app is not approved to ask for transactionsthe manifest lacks wallet:transaction
resolvesThis app is not approved to call that function on that contractthe function is not in that contract's methods
rejectsThis app does not declare "contract" in its manifest (payments.operations)payments does not declare contract (Payments)
resolvesAnother wallet request is already open
resolvesThe transaction names no valid chain
resolvesApps can ask for transactions on EVM chains only
resolvesThis app may ask for transactions on {chain} onlya chain that is neither your token's nor declared
resolvesThis app is not approved for transactions on any chainyour token is not on an EVM chain and you declared none
resolvesThe transaction has no valid recipientto is missing, not an address, or the zero address
resolvesThe transaction data is not valid hexor longer than 64 KB
resolvesThe transaction value is not a valid amount
resolvesAn app may not move that much of {chain}'s coin in one transactiona large value; any value above 0 is refused either way
resolvesThe transaction does nothingno data and no value
resolvesThis token's contract does not take the chain's coina coin payment to your token's contract
resolvesThe transaction data is not a function calldata shorter than a selector
resolvesThe approval names no valid spender
resolvesThis app may not give that address access to your tokensan approval for a spender you did not declare
resolvesThis app may only send this token, not call anything else on it
resolvesThis app is not approved to call that contract
resolvesApps may not send the chain's coin from a wallet; payments go through the checkoutvalue above 0, or a payment of the coin
resolvesApps may not move tokens from a wallet; payments go through the checkouta token or NFT transfer
resolvesApps may not ask for access to your tokens, only revoke itan approval or permit that is not a revoke
resolvesApps pay through the checkout sheet, not by calling the checkout contracta call to the checkout contract
resolvesrejectedthe visitor said no in their wallet
resolvescancelledthe visitor closed the sheet before sending
resolvesthe wallet's message (up to 200 characters)sending failed, and the visitor then closed the sheet

quote ​

Opens your coin's own Buy sheet, where the visitor picks the amount and confirms in their wallet. Needs wallet:trade. Your app never builds the trade.

typescript
const res = await onAction({ type: 'quote', amountUsd: 5 }); // or amount: '1000' (tokens, at the page's price)
// { ok: true,  data: { status: 'bought' } }
// { ok: false, data: { status: 'closed' | 'elsewhere' | 'unavailable' }, error? }

It resolves when the sheet closes:

statusokMeaning
boughttruea buy of your token confirmed while the sheet was open
closedfalsethe visitor closed it without buying
elsewherefalsethe token has no in-app trade; the page showed where it trades
unavailablefalsethe page offers no Buy (error: 'This token cannot be bought here'), or the sheet did not open within 4 seconds (error: 'Buy did not open')

The prefill is amountUsd, or amount in tokens converted at the page's price, capped at $10,000; the visitor can still change it. chainId, tokenIn, amountIn and slippageBps are accepted and ignored.

OutcomeerrorWhen
rejectsThis app is not approved to open Buythe manifest lacks wallet:trade
resolvesAn app can offer buying onlyside is set to anything but buy
resolvesAn app can offer only its own tokentokenOut is set and is not your token
resolvesBuy is already open

Fullscreen ends first, because the Buy sheet belongs to the page.

Selling and spending from your app ​

iap-purchase ​

Sells an item from this token's store: one of the page's own, or one of your app's paid app items. Needs iap:purchase. Your app names the item; the page reads the price from the store, shows it, and the visitor's own wallet pays the seller (and, on your own items, your share).

typescript
const res = await onAction({
  type: 'iap-purchase',
  sku: 'credits.100',
  quantity: 1,                         // optional, a whole number from 1; default 1
  appAccountToken: 'user-42',          // optional: your own id for the buyer, echoed on the order and its receipt
});
// { ok: true, status: 'confirmed', orderId, receipt, data: { status: 'confirmed', orderId, receipt } }

The result is at the top level and again in data. ok is true for confirmed and paid.

The page records which app asked, and an appId in the action is ignored. On its sheet the visitor ticks "Digital item, delivered immediately. All sales are final." before their wallet opens, and presses and holds to buy above the second-confirmation limit ($50).

statusMeaning
confirmedpaid and final; the item is granted and receipt is the signed receipt
paidthe payment was sent but is not final; the item follows without the visitor doing more
cancelledthe visitor closed the sheet, or the price expired, before paying
failedthe purchase could not go ahead; error says why
Outcomeerror (with status: 'failed')When
rejectsThis app is not approved to sell itemsthe manifest lacks iap:purchase
resolvesA purchase is already open
resolvesName the item to buysku is missing or blank
resolvesQuantity must be a whole number, 1 or more
resolvesappAccountToken must be a string
resolvesThis token has no storethe page has no token id or chain
resolvesThis app has asked for too many purchases in the last minute. Try again shortly.more than 5 purchase sheets from your app in a minute
resolvesthe sheet's own messagethe order or the payment failed

On a coin's own site the same action works, unchanged, when you have turned on selling there: the visitor pays in a Chain Daddy window, and a signed-out visitor is asked to sign in first.

The purchase sheet draws over a fullscreen app, which stays fullscreen. An app with iap:purchase also hears when a paid purchase settles and when the visitor's items change, through props.subscribeStore: Hearing when a purchase settles.

Receipts: Verifying receipts.

iap-consume ​

Spends the signed-in visitor's own consumable from this token's store, with no server of yours: a life, a boost, a credit. Needs iap:consume, and an unreviewed build can never ask.

typescript
useBoost.onclick = async () => {
  const res = await onAction({
    type: 'iap-consume',
    sku: 'boost',
    quantity: 1,                    // optional, a whole number from 1; default 1
    key: `race-${raceId}-boost`,    // optional: your id for this spend. Sent again, it spends nothing more
  });
  // { ok: true, data: { sku: 'boost', remaining: 4 } }
  // { ok: false, status: 409, code: 'INSUFFICIENT_QUANTITY', error: 'the wallet does not hold that many' }
};
  • What it can spend. The signed-in visitor's own consumable, in this page's store, and only an item whose owner lets apps spend it. The two halves, and when to spend on your server instead, are in Spending from the app.
  • Tap required. Send it from the visitor's tap, click or key press in your app, or within 2 minutes of one. A gamepad press is not a tap to the browser, so a player on a gamepad spends on their last click or key. The page asks the browser, so a tap your app reports itself does not count. Your app must also be on screen, in a visible tab.
  • Shown. The page shows a toast with your app's name, what was spent and what is left (Spent 1 × Boost · 4 left), so the visitor sees every spend. It draws over a fullscreen app, which stays fullscreen.
  • Retries. With a key (1–128 letters, digits or . _ : -), the same key from the same visitor spends once: sent again, it answers ok: true with what is left now and spends nothing. Without a key every request is its own spend, so send one whenever you might retry.
  • Answer: remaining is how many the visitor holds after the spend. Apps on the page with iap:purchase also hear store:entitlements.

A failed spend carries status: the API's HTTP status, or 0 when the page refused it before sending or the network failed. When the API answered, it also carries the API's code. Check those rather than the error text:

typescript
if (!res.ok && res.code === 'INSUFFICIENT_QUANTITY') offerToBuy('boost');
OutcomestatuscodeerrorWhen
rejectsAn unreviewed app cannot spend itemsan unreviewed build
rejectsThis app is not approved to spend itemsthe manifest lacks iap:consume
resolves0Spending an item needs a tap in the appno tap in your app in the last 2 minutes
resolves0Spending an item needs the app on screenyour app is scrolled out of view, or the tab is hidden
resolves0Name the item to spendsku is missing or blank
resolves0Quantity must be a whole number, 1 or more
resolves0key must be 1-128 letters, digits or . _ : -
resolves0This token has no store
resolvesThis app is asking too often: try again in a momentmore than 5 spends at once, then 1 a second
resolves401Sign in to continuethe visitor is not signed in, or not with a wallet on this coin's chain: send sign-in
resolves409INSUFFICIENT_QUANTITYthe wallet does not hold that manythe visitor holds fewer than quantity; nothing was spent
resolves403APP_SPEND_OFFthis store's owner has not let apps spend this itemthe item's maxAppSpend is not set
resolves400INVALID_REQUESTquantity must be between 1 and {n}, the most an app may spend of this item at a timemore than the item's maxAppSpend
resolves400INVALID_REQUESTonly a consumable can be spent; "{sku}" is not one
resolves404NOT_FOUNDno sku "{sku}" in this store
resolves403APP_NOT_ALLOWEDthis app is not live, this app is not approved to spend items or this app is not installed on this token pagethe platform checks your approved build again
resolves429the API's messageone visitor made more than 120 spends in a minute

Asks for one of your app's paid files: what they are, how to pack them and who is served are there, with a worked example. It needs no permission: the folder in your reviewed package is the declaration.

typescript
const res = await onAction({ type: 'paid-file', path: 'paid/track-pack/monza.bin' });
// { ok: true, data: { url, expiresAt, sku } }: fetch(res.data.url) reads this one file
  • path is the file's path in your package, starting paid/. The page names the store, the wallet and your app; your app names only the file.
  • The URL reads that file and no other, until expiresAt (five minutes on). Start the download before then; a download that has started runs to its end. Ask again for a new URL: nothing else reads the file, and an expired or altered URL is refused.
  • Fetch it yourself, with fetch, or as the src of an image, a sound or a video. Your frame may reach your build's own paid files and nothing beside them. They are data to it: a <script> or a stylesheet is never loaded from there.
  • Ask when you need it. A visitor may ask for 120 files a minute, so pack an item's content into a few files.

A refusal carries status (the API's HTTP status, or 0 when the page refused it before sending) and code:

statuscodeWhen
0path is not in paid/, or the page cannot tell which app is asking
401SIGN_IN_REQUIREDnobody is signed in, or not with a wallet on this coin's chain: send sign-in
403NOT_OWNEDthe visitor does not hold the item now: offer iap-purchase
404NO_STOREthis token has no store, so nobody can hold the item here
404NOT_FOUNDyour live build has no such file, or this page's store has no item with that sku
400INVALID_REQUESTthe folder is named for a consumable
403APP_NOT_ALLOWEDyour app is not live, or not installed on this page
403APP_ITEM_NOT_YOURSthe folder is named for another app's item
503PAID_FILES_UNAVAILABLE or IAP_CHAIN_UNAVAILABLEtry again shortly

pay ​

Pays one or more wallets through the Chain Daddy checkout: one player paying another, a tip, a payment to you. Needs iap:pay. Your app names the wallets and amounts; the page shows them on its own sheet, with the fee, and the visitor's wallet pays each one in a single payment.

typescript
const res = await onAction({
  type: 'pay',
  currency: 'usdc',                    // optional: 'usdc' (the default) or 'token', the page's token
  recipients: [                        // 1 to 3 wallets on the page's chain
    { address: '0x…', amount: '2000000' },   // base units of the currency: 2 USDC
    { address: '0x…', amount: '500000' },
  ],
});
// { ok: true, data: { status: 'confirmed', orderId, receipt } }
  • The fee comes out of each amount. The visitor pays the sum of the amounts; each wallet receives its amount less Chain Daddy's fee (2.5%, or 0% on a coin launched in a Chain Daddy launchpad pool).
  • USDC or the page's token, never the chain's coin.
  • All sales are final. The visitor ticks the final-sale acknowledgement before their wallet opens, and the limits on an app's purchases apply.
  • The answer has the same status values as iap-purchase, and its receipt has kind: 'payment'.

Over HTTP the page sends POST /api/v2/iap/payments {storeId | chainKey + crownId, appId, currency, recipients, acknowledgeFinalSale} with the visitor's own session. A store key cannot make a payment.

Messages ​

notify ​

Sends the signed-in visitor a notification from your app about this token: in-app and by push. Its words never go by text or email: a visitor who turned on texts for apps gets only our "you have new updates" text, at most once a day. Needs notify:user. The page names your app and the token; you write the text.

typescript
const res = await onAction({
  type: 'notify',
  key: 'poll-17-results',          // your id for this message: 1–128 letters, digits or . _ : -
  title: 'Results are in',         // one line, up to 60 characters
  body: 'The vote closed: option B won.', // plain text, up to 280 characters; line breaks allowed
});
// { ok: true, data: { status: 'sent' | 'duplicate' | 'muted' } }

duplicate means that key was sent to this visitor before, so nothing more went out: a retry is safe. muted means the visitor muted your app. At most 5 messages a day from your app to one visitor.

OutcomeerrorWhen
rejectsThis app is not approved to send notificationsthe manifest lacks notify:user
rejectsThis page has no token to send from
resolvesunauthorizedthe visitor is not signed in
resolvesapp message: not allowed: this app is not live
resolvesapp message: not allowed: this app is not approved to send notificationsthe live manifest lacks notify:user
resolvesapp message: invalid: unknown token page
resolvesapp message: invalid: a title and a body are required
resolvesapp message: invalid: the title is {n} characters, at most 60
resolvesapp message: invalid: the body is {n} characters, at most 280
resolvesapp message: invalid: the title must be one line of texta control character in the title
resolvesapp message: invalid: the body must be plain texta control character other than a line break
resolvesapp message: invalid: key must be 1-128 letters, digits or . _ : -
resolvesapp message: daily limit reached: at most 5 messages to one user a day

Media ​

upload-media ​

Pins an image, video or audio file the signed-in visitor picked to IPFS through the platform, and answers with where it is. Your app never handles the upload itself. It needs fetch:self-api, and a tap in your app in the last 2 minutes: send it from the file input's change, after the visitor's tap opened the picker. Each signed-in visitor can upload 60 files an hour across all apps.

typescript
input.onchange = async () => {
  const file = input.files?.[0];
  if (!file) return;
  const res = await onAction({ type: 'upload-media', file });
  // { ok: true, data: { url: 'ipfs://…', cid: '…', contentType: 'image/webp' } }
};

Images must be under 8 MB and are re-encoded (to WebP); video must be under 11 MB and 20 seconds; audio under 5 minutes. The platform reads what the file is from its own bytes, never from the type you give it: PNG, JPEG, GIF, WebP, MP4, MOV, WebM, MKV, Ogg, MP3, AAC, M4A, WAV, FLAC or AIFF. SVG is refused. upload-image takes the same { file } and the same answer for images only. Store url as the reference and resolve it through the first of the page's appData.ipfsGateways, the one your frame may show images and sound from (see sandbox rules).

errorWhen
This app is not approved to upload filesyour manifest lacks fetch:self-api
Uploading needs a tap in the appthe visitor has not tapped or typed in your app in the last 2 minutes
No file providedfile is not a File
Upload failed: {status} {the API's response}the API refused it: 401 signed out, 400 an unsupported file type, 413 too large (the response says the limit), 429 this visitor's hourly uploads are used up
the network error's message, or Upload failedthe upload did not complete

Fullscreen ​

fullscreen ​

Asks the page to show your app fullscreen (enabled: true) or to leave it (false). Needs ui:fullscreen. requestFullscreen() inside your frame does not work.

typescript
await onAction({ type: 'fullscreen', enabled: true }); // { ok: true }
// then read props.isFullscreen
  • Answer: resolves { ok: true } at once, and the page goes straight to fullscreen, with no prompt. Send it from the visitor's tap in your app (a Play button): the browser lets the page take the screen for that tap. Sent without one, the next tap in your app takes it. The page asks the browser whether the visitor tapped, so a tap your app reports itself does not count. isFullscreen in your props says what happened.
  • Refused: rejects This app is not approved for fullscreen without the permission.
  • For the first three seconds, a small platform notice sits over the top left of your app: your app's name, "third-party app", and Exit. Then it goes; your app stays the same size throughout. Give players your own way out (a phone has no Esc): an exit control that sends { type: 'fullscreen', enabled: false }. Esc leaves too, and so does Back, which keeps the visitor on the page.
  • A game (an engine app, or any app filed under games) also gets the page's HUD in the corners while it is fullscreen: Keep the corners clear.
  • Phones: where the browser has its own fullscreen (Android, iPad, desktop), you get it, and it turns with the device. An iPhone browser has none for a page, so your app covers the page instead and resizes when the phone turns; the browser's own bars keep their part of the screen. Added to the Home Screen, the page gets the whole screen. Read your size from your own resize events, never once at start.
  • connect-wallet, sign-in, sign-typed-data, quote and navigate end fullscreen first; send fullscreen again once you have the answer. A purchase, a transaction and a dialog draw over your app instead.

Poll votes ​

A vote is signed by the visitor with sign-typed-data and sent with api-call. The platform verifies this EIP-712 data exactly:

typescript
const domain = {
  name: 'OpenCrown',
  version: '1',
  chainId: Number(appData.chainId), // this page's chain id; no verifyingContract
};
const types = {
  PollVote: [
    { name: 'pollId', type: 'string' },
    { name: 'optionIndex', type: 'uint256' },
    { name: 'voterWallet', type: 'address' },
    { name: 'timestamp', type: 'uint256' },
  ],
};
const timestamp = Math.floor(Date.now() / 1000);
const message = { pollId, optionIndex, voterWallet: connectedAddress, timestamp };

const signed = await onAction({ type: 'sign-typed-data', domain, types, primaryType: 'PollVote', message });
if (signed.ok) {
  await onAction({
    type: 'api-call',
    method: 'POST',
    endpoint: `/api/v2/polls/${encodeURIComponent(pollId)}/vote`,
    body: { optionIndex, signature: signed.data, timestamp },
  });
}

The domain types are name (string), version (string) and chainId (uint256). The page sets chainId in the vote's body to this page's chain, and the platform checks the signature against that chain, so sign with appData.chainId.

The vote answers 201 with the vote (id, pollId, voterWallet, optionIndex, tokenBalanceAtSnapshot, signature, votedAt, updatedAt) and the tally it made, results and sufficientParticipation, in the shape GET /api/v2/polls/{pollId}/results answers with. Show that tally after a vote: the results endpoint is cached for up to a minute, so reading it right after voting can show the count from before the vote. Both tally fields are absent when the tally could not be read. Voting again replaces the vote. It is refused when the visitor is not signed in, the signature is not from the signed-in wallet (signature does not match voter wallet), the timestamp is more than 10 minutes old or more than a minute ahead, the poll is not open (poll is not open for voting (phase: …)), or the wallet held too little of the token at the poll's snapshot (insufficient token balance: have …, need …).

App config ​

Each coin page that installs your app keeps a config for it: up to 64 KB of JSON, yours to shape. The owner or a manager of the token writes it; anyone can read it (unless the app is holders-only, below), and your runner receives it with every event.

GET    /api/v2/registration/{id}/apps/{app}/config?chain={chain}
PUT    /api/v2/registration/{id}/apps/{app}/config?chain={chain}   { "config": { … } }
DELETE /api/v2/registration/{id}/apps/{app}/config?chain={chain}

{id} is the token id, {app} your app's id and {chain} the page's chain (a chain key such as base, a chain id or eip155:8453). Always send ?chain=: token ids repeat across chains.

  • GET answers { "crownId": 42, "appId": "…", "chain": "eip155:8453", "config": { … } }, with {} for a config never set, and 404 app is not enabled on this token page when the app is not installed there.
  • Holders-only apps. When the token's owner makes your app holders-only (for one of their holder groups), the config goes whole to the owner, the token's managers, and signed-in holders in that group (holders only while the owner's plan includes holders-only content). Anyone else gets "config": { "_locked": true, "_accessTierName": "Whales", "_lockReason": "not_in_group" }: a lock, the group's name and why, nothing of your config. _lockReason is "paused" when the owner's plan no longer includes holders-only content (then nobody but the owner and managers opens it, holders included), else "not_in_group". The page shows such a visitor a lock over your app rather than the app itself. When only some of your app's places on the page are holders-only, everyone reads the config, because the public places show it.
  • PUT needs a signed-in owner or manager of the token. It replaces the config (null or no config clears it to {}) and answers the same shape with "success": true. Refusals: 400 config must be valid JSON, 413 config body exceeds 64KB, 404 app is not enabled on this token page, 409 for a config that is a lock (read before the visitor signed in; read it again and save that), and 401/403 for anyone else. Your runners receive a token.app.configured event with the new config.
  • DELETE clears it, with the same rule on who may.

From your app, read it with api-call GET and write it with api-call PUT (which reaches your own app's config only); the page's session decides whether the visitor may.

Runner Action API ​

A runner's own calls to the platform (signing, storage, rate limits) are in Runners and events.