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:
| Outcome | Meaning |
|---|---|
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 Error | The 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 undefined | A 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:
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
| Action | Permission | The visitor's tap | Unreviewed build | Success data |
|---|---|---|---|---|
navigate | navigate:internal | required | allowed | none (undefined) |
clipboard | clipboard:write | required | allowed | none |
track | none | no | allowed | none |
toast | none | no | allowed | none |
modal | none | no | allowed | { button } |
api-call | none (a drop claim needs iap:purchase) | no | reads only | the endpoint's JSON |
connect-wallet | none | required | allowed | none |
sign-in | none | required | allowed | { signedIn: true } |
sign-typed-data | wallet:sign | no | refused | the signature |
sign-message | wallet:sign | no | refused | { signature, message, publicKey } |
transaction | wallet:transaction | no | refused | { txHash, chainId } |
quote | wallet:trade | no | allowed | { status } |
iap-purchase | iap:purchase | no | allowed | { status, orderId, receipt } |
iap-consume | iap:consume | required | refused | { sku, remaining } |
paid-file | none | no | your own files, with no purchase | { url, expiresAt, sku } |
pay | iap:pay | no | allowed | { status, orderId, receipt } |
notify | notify:user | no | allowed | { status } |
session-token | net:connect | no | allowed | { token, expiresAt } |
upload-media | fetch:self-api | required | allowed | { url, cid, contentType } |
fullscreen | ui:fullscreen | send it from a tap in your app | allowed | none ({ ok: true }) |
open-settings | none | no | allowed | none |
audio | none | no | allowed | none ({ 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.
Navigation and small actions
navigate
Moves the visitor to another page of the site, in the same tab.
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' }withoutnavigate: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
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' }withoutclipboard: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.
onAction({ type: 'track', event: 'website', data: { from: 'menu' } });- Answer: resolves
undefined. eventis kept when it names one of the page's link types (website,twitter,telegram,discord,github,whitepaper,email,ens); any other name is counted ascustom.data, when sent, is kept as its JSON text.
toast
Shows a short notice on the page. It answers nothing.
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.
onAction({ type: 'connect-wallet' });- Answer: resolves
undefinedat once, before the visitor connects. WatchconnectedAddressin 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).
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.
onAction({ type: 'audio', background: true });- Answer: resolves
{ ok: true }, from the frame.background: falsegoes back to the default. The visitor's own mute and volume still apply.
Dialogs
modal
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.
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 itTitle, body and labels are trimmed. A style other than primary is drawn as secondary.
error | When |
|---|---|
A dialog needs a title | title is missing or blank |
A dialog title is at most 80 characters | |
A dialog body is text | body is not a string |
A dialog body is at most 600 characters | |
A dialog has 1 to 3 buttons | buttons 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 open | another 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.
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:
| Method | Path | Notes |
|---|---|---|
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}/claim | Claiming a reward drop for the visitor. Needs iap:purchase. |
POST | /api/v2/polls/{pollId}/vote | A 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).
| Outcome | error | When |
|---|---|---|
| resolves | Endpoint not allowed for widgets | the path is on no list above, or holds .., %2e or a backslash |
| resolves | This app can only reach this token's own data | /api/v2/registration/{id}/… names another token, or ?chain= another chain |
| resolves | This app can only read this, not change it | any other write (a DELETE included) to a path it may read |
| resolves | An unreviewed app can only read, not change anything | any method but GET from an unreviewed build |
| resolves | This app is not approved to use the store | a drop claim from an app without iap:purchase |
| resolves | the 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:
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.
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 incovers a declined signature, a failed sign-in, and 2 minutes with no answer. A second request while one is open shares its answer. - Your
signedInprop 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 onconnectedAddress, which stays the same across a sign-out. - Fullscreen: ends it for the wallet; send
fullscreenagain 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.
const res = await onAction({ type: 'session-token' });
// { ok: true, data: { token: 'eyJ…', expiresAt: 1790000300 } } expiresAt: Unix secondserror | When |
|---|---|
This app is not approved to connect to a server | the manifest lacks net:connect |
Sign in to continue | the visitor is not signed in: send sign-in |
Could not start a session | anything 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.
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 signatureBefore 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.namemust be your app id (or its slug, the part after the/) or your app's listing name exactly, anddomain.chainIdyour token's chain or one in the manifest'swallet.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 inwallet.contracts, ondomain.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-purchaseandpay). - 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
transactioninstead), and Chain Daddy's own signatures, except the poll vote. Adomain.namewith "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.
| Outcome | error | When |
|---|---|---|
| rejects | An unreviewed app cannot ask for signatures | an unreviewed build |
| resolves | Another wallet request is already open | a transaction, signature or purchase is open |
| resolves | This signature request is incomplete | domain, 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 |
| resolves | This signature request's <field> is not a valid <type>, This signature request is missing <field> | a value does not match its type |
| resolves | This signature request names a contract that is not an address | |
| resolves | This signature request names a network that does not exist | chainId is not a positive whole number |
| resolves | Apps may not ask for a signature that gives access to your tokens | a grant (a permit, a transfer, a Safe or forwarded transaction, an order or a delegation) to a contract the manifest declares |
| resolves | This app may not ask for <a grant> for a contract it did not declare | a grant's contract is not in wallet.contracts |
| resolves | This app is not approved to ask for signatures for that contract | verifyingContract is not in wallet.contracts |
| resolves | An 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 | |
| resolves | This app may ask for signatures only on its own networks (domain.chainId) | |
| resolves | This app may not ask for a signature over a hidden message, … for a signed transaction | |
| resolves | Apps may not ask for Chain Daddy's own signatures | |
| resolves | This app was stopped from using your wallet on this page | the page blocked an earlier request from your app |
| resolves | Signing canceled | the visitor closed the sheet, or said no in their wallet |
| resolves | the 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:
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:
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 ofA-Z a-z 0-9 _ -.signatureis the Ed25519 signature in base58,publicKeythe 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.
| Outcome | error | When |
|---|---|---|
| rejects | An unreviewed app cannot ask for signatures | an unreviewed build |
| rejects | This app is not approved to ask for signatures | the manifest lacks wallet:sign |
| resolves | sign-message works only on a Solana coin | the coin is on an EVM chain: use sign-typed-data |
| resolves | A message needs a statement and a nonce | either is missing or not a string |
| resolves | The statement must be 1 to 280 characters, with no space at either end | |
| resolves | The statement must be one line of plain text | a line break, or a hidden or direction-changing character |
| resolves | The nonce must be 8 to 64 letters, digits, "_" or "-" | |
| resolves | Another wallet request is already open | a transaction, signature or purchase is open |
| resolves | This wallet can't sign messages for apps. Try a different Solana wallet. | the wallet signed something other than the text (some hardware wallets do) |
| resolves | Signing canceled | the visitor closed the sheet, or said no in their wallet |
| resolves | This page has no "sign-message" action | the page does not offer it yet |
transaction
Proposes one EVM transaction. Needs wallet:transaction, and an unreviewed build can never ask.
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 themethodsyou declared for it, in a way that moves no value. - No value moves. An app moves value only through the checkout (
iap-purchaseandpay), so a transaction may not send the chain's coin (valuemust be 0), move tokens (transfer,transferFrom, and NFT safe transfers), or call the Chain Daddy checkout contract. - Approvals.
approve,increaseAllowance,permit(and DAI's), Permit2approveandsetApprovalForAllare refused. Revoking one (an amount of 0, orfalse) 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.
| Outcome | error | When |
|---|---|---|
| rejects | An unreviewed app cannot ask for transactions | an unreviewed build |
| rejects | This app is not approved to ask for transactions | the manifest lacks wallet:transaction |
| resolves | This app is not approved to call that function on that contract | the function is not in that contract's methods |
| rejects | This app does not declare "contract" in its manifest (payments.operations) | payments does not declare contract (Payments) |
| resolves | Another wallet request is already open | |
| resolves | The transaction names no valid chain | |
| resolves | Apps can ask for transactions on EVM chains only | |
| resolves | This app may ask for transactions on {chain} only | a chain that is neither your token's nor declared |
| resolves | This app is not approved for transactions on any chain | your token is not on an EVM chain and you declared none |
| resolves | The transaction has no valid recipient | to is missing, not an address, or the zero address |
| resolves | The transaction data is not valid hex | or longer than 64 KB |
| resolves | The transaction value is not a valid amount | |
| resolves | An app may not move that much of {chain}'s coin in one transaction | a large value; any value above 0 is refused either way |
| resolves | The transaction does nothing | no data and no value |
| resolves | This token's contract does not take the chain's coin | a coin payment to your token's contract |
| resolves | The transaction data is not a function call | data shorter than a selector |
| resolves | The approval names no valid spender | |
| resolves | This app may not give that address access to your tokens | an approval for a spender you did not declare |
| resolves | This app may only send this token, not call anything else on it | |
| resolves | This app is not approved to call that contract | |
| resolves | Apps may not send the chain's coin from a wallet; payments go through the checkout | value above 0, or a payment of the coin |
| resolves | Apps may not move tokens from a wallet; payments go through the checkout | a token or NFT transfer |
| resolves | Apps may not ask for access to your tokens, only revoke it | an approval or permit that is not a revoke |
| resolves | Apps pay through the checkout sheet, not by calling the checkout contract | a call to the checkout contract |
| resolves | rejected | the visitor said no in their wallet |
| resolves | cancelled | the visitor closed the sheet before sending |
| resolves | the 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.
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:
status | ok | Meaning |
|---|---|---|
bought | true | a buy of your token confirmed while the sheet was open |
closed | false | the visitor closed it without buying |
elsewhere | false | the token has no in-app trade; the page showed where it trades |
unavailable | false | the 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.
| Outcome | error | When |
|---|---|---|
| rejects | This app is not approved to open Buy | the manifest lacks wallet:trade |
| resolves | An app can offer buying only | side is set to anything but buy |
| resolves | An app can offer only its own token | tokenOut is set and is not your token |
| resolves | Buy 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).
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).
status | Meaning |
|---|---|
confirmed | paid and final; the item is granted and receipt is the signed receipt |
paid | the payment was sent but is not final; the item follows without the visitor doing more |
cancelled | the visitor closed the sheet, or the price expired, before paying |
failed | the purchase could not go ahead; error says why |
| Outcome | error (with status: 'failed') | When |
|---|---|---|
| rejects | This app is not approved to sell items | the manifest lacks iap:purchase |
| resolves | A purchase is already open | |
| resolves | Name the item to buy | sku is missing or blank |
| resolves | Quantity must be a whole number, 1 or more | |
| resolves | appAccountToken must be a string | |
| resolves | This token has no store | the page has no token id or chain |
| resolves | This 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 |
| resolves | the sheet's own message | the 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.
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 answersok: truewith what is left now and spends nothing. Without akeyevery request is its own spend, so send one whenever you might retry. - Answer:
remainingis how many the visitor holds after the spend. Apps on the page withiap:purchasealso hearstore: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:
if (!res.ok && res.code === 'INSUFFICIENT_QUANTITY') offerToBuy('boost');| Outcome | status | code | error | When |
|---|---|---|---|---|
| rejects | An unreviewed app cannot spend items | an unreviewed build | ||
| rejects | This app is not approved to spend items | the manifest lacks iap:consume | ||
| resolves | 0 | Spending an item needs a tap in the app | no tap in your app in the last 2 minutes | |
| resolves | 0 | Spending an item needs the app on screen | your app is scrolled out of view, or the tab is hidden | |
| resolves | 0 | Name the item to spend | sku is missing or blank | |
| resolves | 0 | Quantity must be a whole number, 1 or more | ||
| resolves | 0 | key must be 1-128 letters, digits or . _ : - | ||
| resolves | 0 | This token has no store | ||
| resolves | This app is asking too often: try again in a moment | more than 5 spends at once, then 1 a second | ||
| resolves | 401 | Sign in to continue | the visitor is not signed in, or not with a wallet on this coin's chain: send sign-in | |
| resolves | 409 | INSUFFICIENT_QUANTITY | the wallet does not hold that many | the visitor holds fewer than quantity; nothing was spent |
| resolves | 403 | APP_SPEND_OFF | this store's owner has not let apps spend this item | the item's maxAppSpend is not set |
| resolves | 400 | INVALID_REQUEST | quantity must be between 1 and {n}, the most an app may spend of this item at a time | more than the item's maxAppSpend |
| resolves | 400 | INVALID_REQUEST | only a consumable can be spent; "{sku}" is not one | |
| resolves | 404 | NOT_FOUND | no sku "{sku}" in this store | |
| resolves | 403 | APP_NOT_ALLOWED | this app is not live, this app is not approved to spend items or this app is not installed on this token page | the platform checks your approved build again |
| resolves | 429 | the API's message | one visitor made more than 120 spends in a minute |
paid-file
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.
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 filepathis the file's path in your package, startingpaid/. 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 thesrcof 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:
status | code | When |
|---|---|---|
0 | path is not in paid/, or the page cannot tell which app is asking | |
401 | SIGN_IN_REQUIRED | nobody is signed in, or not with a wallet on this coin's chain: send sign-in |
403 | NOT_OWNED | the visitor does not hold the item now: offer iap-purchase |
404 | NO_STORE | this token has no store, so nobody can hold the item here |
404 | NOT_FOUND | your live build has no such file, or this page's store has no item with that sku |
400 | INVALID_REQUEST | the folder is named for a consumable |
403 | APP_NOT_ALLOWED | your app is not live, or not installed on this page |
403 | APP_ITEM_NOT_YOURS | the folder is named for another app's item |
503 | PAID_FILES_UNAVAILABLE or IAP_CHAIN_UNAVAILABLE | try 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.
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
statusvalues asiap-purchase, and its receipt haskind: '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.
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.
| Outcome | error | When |
|---|---|---|
| rejects | This app is not approved to send notifications | the manifest lacks notify:user |
| rejects | This page has no token to send from | |
| resolves | unauthorized | the visitor is not signed in |
| resolves | app message: not allowed: this app is not live | |
| resolves | app message: not allowed: this app is not approved to send notifications | the live manifest lacks notify:user |
| resolves | app message: invalid: unknown token page | |
| resolves | app message: invalid: a title and a body are required | |
| resolves | app message: invalid: the title is {n} characters, at most 60 | |
| resolves | app message: invalid: the body is {n} characters, at most 280 | |
| resolves | app message: invalid: the title must be one line of text | a control character in the title |
| resolves | app message: invalid: the body must be plain text | a control character other than a line break |
| resolves | app message: invalid: key must be 1-128 letters, digits or . _ : - | |
| resolves | app 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.
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).
error | When |
|---|---|
This app is not approved to upload files | your manifest lacks fetch:self-api |
Uploading needs a tap in the app | the visitor has not tapped or typed in your app in the last 2 minutes |
No file provided | file 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 failed | the 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.
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.isFullscreenin your props says what happened. - Refused: rejects
This app is not approved for fullscreenwithout 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,quoteandnavigateend fullscreen first; sendfullscreenagain 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:
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.
GETanswers{ "crownId": 42, "appId": "…", "chain": "eip155:8453", "config": { … } }, with{}for a config never set, and404 app is not enabled on this token pagewhen 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._lockReasonis"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. PUTneeds a signed-in owner or manager of the token. It replaces the config (nullor noconfigclears 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,409for a config that is a lock (read before the visitor signed in; read it again and save that), and401/403for anyone else. Your runners receive atoken.app.configuredevent with the new config.DELETEclears 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.