Give it developer.batiyao.com/miniapps.md: this whole page as one Markdown file, with every rule Batiyao checks. /llms.txt lists all the documentation.
Batiyao Mini Apps — the complete developer specification
A mini app is a small HTML5 game or tool that runs inside Batiyao (on batiyao.com and in the Batiyao apps for iPhone and Android). You write a folder of HTML, CSS and JavaScript, zip it and upload it. Batiyao hosts it, lists it, reviews it and runs it sealed off from the internet. You host nothing and run no server. It can also ask Batiyao's own AI, on the person's phone, for pictures, sounds and answers (section 12).
This page is the whole specification. Everything Batiyao checks is written here, with the exact limits. It is written to be followed by a person or by an AI coding agent.
- Sample app (a complete game): https://github.com/batiyao-app/developer-sample/tree/main/miniapp/wingbeat
- SDK (one file): https://github.com/batiyao-app/developer-sample/blob/main/miniapp-sdk/batiyao-miniapp.js
- Simulator and packer: https://github.com/batiyao-app/developer-sample (
npm run miniapp,npm run pack:miniapp) - Register and upload: https://batiyao.com/apps?tab=built
- This file as Markdown: https://developer.batiyao.com/miniapps.md
1. Quick start
- Register the app. Sign in at https://batiyao.com/apps?tab=built, type the app's name, choose its category, press New app. You get an App ID like
ma_abcdefgh23. Anybody with a Batiyao account and a date of birth on it can do this, for up to 5 apps; more as a Batiyao partner (section 14). - Make a folder with
manifest.json, your entry page (index.html), a square PNG icon and a copy ofbatiyao-miniapp.js. Start from the template in section 17. - Test it locally with the simulator:
npm run miniapp -- path/to/folderin the sample repo, then open http://localhost:3100. - Zip the folder's contents (not the folder itself): the zip must contain a file whose path is exactly
manifest.json, at its root. The order of entries doesn't matter and folder entries are ignored. The packer checks every rule below first:npm run pack:miniapp -- path/to/folder --app-id ma_abcdefgh23. - Upload the zip on your app's page and press Test on the version: it runs exactly as people will see it, with payments that move nothing.
- Review. An admin checks the version. If nobody acts within 1 hour, it goes live by itself, unless the checks flagged something (section 5.3) or it asks for Batiyao's AI for the first time (section 12.1).
2. The environment your code runs in
Your app's pages run in a sandboxed frame with no origin (self.origin is "null"). Batiyao draws its own bar above your app (close, app name, report); your app gets the rest of the screen.
| Works | Doesn't work (and what to do instead) |
|---|---|
HTML, CSS, JavaScript, canvas, WebGL, WebAssembly, eval/new Function | Any request to another host: fetch, XHR, WebSocket, EventSource, sendBeacon, images/fonts/scripts from a URL — bundle every file |
fetch('data/levels.json') and XHR for files in your bundle, by relative path | WebRTC (RTCPeerConnection is removed before your code runs) — for live play with others use Batiyao.multiplayer (section 11) |
<img>, <audio>, <video>, fonts, CSS from your bundle; data: and blob: URLs | Cookies, localStorage, sessionStorage, IndexedDB, Cache API (no origin) — use Batiyao.storage |
Web Workers created from a blob: URL | new Worker('worker.js') by path — fetch the script text and create the worker from a Blob |
| Pointer, touch and keyboard input | alert, confirm, prompt, print (no modals) — draw your own dialogs |
Web Audio and <audio> once the person has tapped | Pop-ups, window.open, links that leave the app, navigating the page anywhere else |
<form> with JavaScript handlers (event.preventDefault()) | Submitting a form to a URL |
Offering a picture and words for the person to share (Batiyao.share) | Posting, sharing or messaging by itself |
Data every player of your app can read, such as a champions list (Batiyao.db), and players' nicknames (Batiyao.getNickname) (section 10) | Your own server or database: there is none to reach |
A photo or video the person chooses on Batiyao's picker (Batiyao.pickImage, Batiyao.pickVideo, section 9) | <input type="file">, dropping or pasting files: no file chooser opens and no file can be read — use pickImage/pickVideo |
Pictures, sounds and answers from Batiyao's AI on the person's phone (Batiyao.ai, section 12), including web search results without links | Calling an AI service of your own: there is no network — use Batiyao.ai |
| Camera, microphone, location, notifications, clipboard, fullscreen, pointer lock, screen wake lock, sensors | |
<iframe>, <object>, <embed> |
Every bundle file is served with this Content-Security-Policy, where BASE is your version's own folder on miniapps.batiyao.com:
sandbox allow-scripts allow-forms; default-src 'none';
script-src BASE 'unsafe-inline' 'unsafe-eval' 'wasm-unsafe-eval'; style-src BASE 'unsafe-inline';
img-src BASE data: blob:; media-src BASE data: blob:; font-src BASE data:; connect-src BASE data: blob:;
worker-src blob:; frame-src 'none'; child-src 'none'; object-src 'none'; manifest-src 'none';
form-action 'none'; base-uri 'none'
plus Permissions-Policy denying every powerful feature, and a small script Batiyao puts before your own that removes the WebRTC constructors.
Orientation is set from your manifest (portrait, landscape or any). Make the layout fill the frame (html, body { height: 100%; margin: 0 }) and work from about 320px wide. On the web, a portrait app is shown at most about 450px wide.
3. manifest.json
At the top of the zip. UTF-8 JSON. Unknown fields are refused.
{
"batiyao_miniapp": 1,
"app_id": "ma_abcdefgh23",
"version": "1.0.0",
"name": "Wingbeat",
"summary": "Tap to flap through the gaps. How far can you fly?",
"description": "Optional longer text for the app's page.",
"category": "games",
"age_rating": "4+",
"entry": "index.html",
"icon": "icon.png",
"screenshots": ["shots/1.png"],
"orientation": "portrait",
"access": { "type": "free" },
"products": [
{ "id": "golden_feathers", "title": "3 golden feathers",
"statement": "3 golden feathers for Wingbeat. Each one lets you keep flying after a crash.",
"price": "0.5", "kind": "consumable" },
{ "id": "night_sky", "title": "Night sky",
"statement": "The night sky theme for Wingbeat, yours to keep.",
"price": "1", "kind": "permanent" }
]
}
| Field | Required | Rule |
|---|---|---|
batiyao_miniapp | yes | Must be 1. |
app_id | yes | Your App ID exactly, matching ^ma_[a-z2-7]{10}$. An upload whose app_id isn't this app's is refused. |
version | yes | MAJOR.MINOR.PATCH, each 0–9999 with no leading zeros (1.0.0, 2.10.3). Each upload must be higher than every version uploaded before. |
name | yes | 2–30 characters, one line. |
summary | yes | 10–80 characters, one line. Shown in lists. |
description | no | 1–2,000 characters; line breaks allowed. Shown on the app's page. |
category | no | Choose it on your app's page (Built by me → your app → Category); a change there shows at once, with no upload. Here it is optional, used only until you choose one on the page; if given it must be one of games, education, tools, entertainment, lifestyle, productivity, other. The list, with display names, is GET https://api.batiyao.com/api/v1/miniapps/categories. |
age_rating | yes | One of 4+, 9+, 13+, 16+, 18+. Rate the content as the app stores do: 4+ nothing objectionable; 9+ mild cartoon violence or scares; 13+ mild violence, crude humour, simulated gambling with no real prize; 16+ stronger violence or mature themes; 18+ adult content. Selling items doesn't change the rating. An admin checks. See section 13. |
entry | no | Path of an .html/.htm file in the bundle. Default index.html. |
icon | yes | Path of a PNG in the bundle, square, 128–1024 pixels. Batiyao rounds its corners; keep what matters in the middle 80% and use an opaque background. |
screenshots | no | Up to 5 paths of PNG, JPEG or WebP files in the bundle. |
orientation | no | portrait, landscape or any (default any). |
access | yes | What opening the app costs. {"type": "free"} (no price or statement), or {"type": "one_time" or "pass_30d", "price": "…", "statement": "…"}. See section 7.4. |
products | no | Up to 50 things the app sells inside itself. |
requires | no | Features your app can't work without, by SDK name: pay, share, pickImage, pickVideo, db, nickname, multiplayer, ai.image, ai.audio, ai.assistant (also storage, purchases). A Batiyao without one of them never opens your app; the person sees "needs a newer version of Batiyao" or "open it in the Batiyao app" instead of a broken game. For anything you can do without, leave it out and check supports() / capability() instead. See section 12.4. |
capabilities | no | Batiyao's AI your app asks to use (section 12): {"ai.image": {"version": 1, "purpose": "Draws a card for each level."}}. A Batiyao admin decides; the purpose is shown to people on your app's page before they open it. |
Each product:
| Field | Rule |
|---|---|
id | ^[a-z0-9_]{1,40}$, unique in the manifest. This is what you pass to Batiyao.pay(). |
title | 1–40 characters, one line. |
statement | 10–140 characters, one line. Says plainly what the person gets. Batiyao shows it, word for word, on the payment sheet. |
price | BuckBuck as a string: digits with up to 3 decimals ("0.5", "2", "1.25"), from 0.1 to 20. |
kind | consumable (can be bought again; you mark each one delivered with consume()) or permanent (bought once, owned forever). |
Prices for access: 0.1 to 50 BuckBuck, same format. Text fields may not start or end with spaces or contain control characters; only description may contain line breaks. Accounts under 18 can publish free apps only (access free and no products).
4. The bundle (the zip)
| Rule | Limit |
|---|---|
| Zip size | at most 20 MB |
| Unpacked size | at most 60 MB in total |
| One file | at most 20 MB |
| Number of files | at most 1,000 |
| Compression | ordinary zip: stored or deflate only; no encryption |
| Compression ratio | a file over 1 MB may not compress more than 100 to 1 |
| Files | ordinary files only: no symlinks, no special files; entries may not overlap |
| File names | each part matches ^[A-Za-z0-9_-][A-Za-z0-9._-]*$ (no spaces, no hidden files, no ..), parts separated by /, at most 10 levels and 180 characters |
| Case | two paths that differ only in capital letters are refused (phones treat them as one file) |
| Dropped silently | __MACOSX/…, .DS_Store, Thumbs.db, desktop.ini, and folder entries |
| Refused | any other hidden file (.gitignore, .env, …): leave them out of the zip |
Allowed file types — the extension decides how a file is served, and the first bytes must match the extension (a .png must really be a PNG):
html htm js mjs css json map txt csv svg png jpg jpeg gif webp avif ico mp3 m4a ogg oga opus wav mp4 webm woff woff2 ttf otf wasm glb gltf bin data
Text files (html htm js mjs css json map txt csv svg gltf) must be valid UTF-8.
5. What the upload checks in your code
5.1 Refused: anything that reaches outside the bundle
In HTML (scripts and comments are ignored for these checks, inline <style> and style="…" are checked as CSS):
- An attribute (
src,href,srcset,poster,action,formaction,data— the<object>attribute, notdata-*ones —,background,manifest,ping,cite,longdesc,xlink:href) whose value starts withhttp:,https:,//,ws:,wss:orftp:. - A path attribute (
src,href,poster,action,data,background,manifest) starting with/. Use paths relative to the page:js/app.js, not/js/app.js. <base>,<meta http-equiv="refresh">.<link rel="preconnect | dns-prefetch | prefetch | prerender">.<iframe>,<frame>,<frameset>,<object>,<embed>,<portal>.
In CSS (files and inline):
url(…)or@importpointing tohttp:,https:or//.url(/…): use a path relative to the stylesheet.
In SVG: <script>, event-handler attributes (onload= …), javascript: URLs, and links to the internet.
5.2 Not checked, but blocked when it runs
JavaScript is not refused for mentioning a URL, but any request to the internet fails at run time (section 2). Don't ship code paths that depend on it — analytics, ads, CDNs, fonts from Google, remote config — they will just fail.
5.3 Flagged: accepted, but waits for an admin
These are accepted, but such a version never approves itself after an hour; an admin must look at it:
Found in a .js/.mjs file or inline script | Flag |
|---|---|
RTCPeerConnection / webkitRTCPeerConnection | Uses WebRTC |
new WebSocket | Opens WebSockets |
EventSource( | Uses EventSource |
sendBeacon( | Uses navigator.sendBeacon |
None of these can work in a mini app; remove them (often they come from a bundled analytics or multiplayer library).
A version also waits for an admin when its manifest.json asks for Batiyao's AI that your app hasn't been granted yet, or for a higher version or a new tool of it (section 12.1). That isn't a problem to fix: it's the admin deciding.
6. The SDK — batiyao-miniapp.js
Copy the file into your bundle and load it before your own code:
<script src="batiyao-miniapp.js"></script>
<script src="app.js"></script>
It defines window.Batiyao. Every method except on and off returns a Promise. A rejected Promise carries an Error with a code (section 16). Outside Batiyao (opened directly in a browser tab) every call rejects with NO_HOST.
| Call | Resolves to | Notes |
|---|---|---|
Batiyao.ready() | {} | Call as soon as something is on screen — don't wait for storage or purchases first; load those after and update the screen. Batiyao shows a loading cover until then (and removes it anyway 3 s after the page loads). |
Batiyao.getContext() | { app_user_id, platform, locale, theme, version, test_mode, access, features?, capabilities? } | app_user_id: a string that is the same for this person every time in your app and different in every other app; use it as your player id. platform: "web", "ios" or "android". locale: e.g. "en-GB". theme: "light" or "dark". version: your running version. test_mode: true in a test run. access: { type, active }. features: the optional calls this Batiyao has, e.g. ["pay", "share", "pickImage", "pickVideo", "db", "nickname", "multiplayer"] (multiplayer only where this person can play in your app; ai.image, ai.audio, ai.assistant only where they work for this person); missing in older phone apps — use supports(). capabilities: Batiyao's AI your manifest declares, with its state — read it with capability() (section 12). |
Batiyao.capability(name) | { supported, version, state, tools } | Section 12: whether Batiyao's AI (ai.image, ai.audio, ai.assistant) works here. |
Batiyao.ai.image.generate(…), .ai.audio.generate(…), .ai.ask(…), .ai.cancel() | see section 12 | Pictures (BGen), sounds (BAudio) and answers (Batiyao AI), made on the person's phone. |
Batiyao.supports(name) | true or false | Whether the Batiyao your app is running in has a call, e.g. "pickImage", "db", "nickname", "multiplayer". Phone apps update on their own schedule, so a new call can be missing for a while: hide what needs it. |
Batiyao.storage.get(key) | the stored string, or null | |
Batiyao.storage.set(key, value) | true | value must be a string of at most 4,096 characters (JSON.stringify objects). |
Batiyao.storage.remove(key) | true | |
Batiyao.storage.keys() | array of keys | |
Batiyao.pay({ product }) | { status, purchase?, reason? } | Section 7. Must be called from a tap or click. |
Batiyao.share({ image, text }) | {} | Section 8: the person may post it, put it in their story or send it to a chat. Must be called from a tap or click. Resolves when Batiyao's share sheet closes; never says whether or where the person shared. |
Batiyao.pickImage({ max_size }) | { status, image?, type?, width?, height? } | Section 9: a photo the person chooses on Batiyao's picker, as a Blob, without its location or other details. status is "picked", "cancelled" or "unavailable". Must be called from a tap or click. |
Batiyao.pickVideo({ max_seconds }) | { status, video?, type?, size?, duration?, width?, height? } | Section 9: the same for a video (up to 100 MB). Must be called from a tap or click. |
Batiyao.getNickname({ ask }) | { nickname } (a string, or null) | Section 10.1. With ask: true and no nickname yet, Batiyao's sheet asks the person (call it from a tap). |
Batiyao.db.set(key, value, { replaceable, rank }) | { saved } | Section 10.2: write your own record to the data every player can read. |
Batiyao.db.get(key, { player }) | a record, or null | Your own record, or another player's. |
Batiyao.db.list(key, { order, limit, cursor }) | { records, next } | Every player's record under key: a leaderboard. |
Batiyao.db.remove(key) / Batiyao.db.usage() | true / { bytes, limit } | |
Batiyao.multiplayer.host(…), .join(…), .list(), .send(…), .leave(), .on(event, fn) | see section 11 | Live matches of 2–8 players, where a Batiyao admin has switched multiplayer on for your app. |
Batiyao.getPurchases() | { purchases: [{ id, product, kind, purchased_at, consumed_at? }], access: { type, active, until? } } | The record of what this person owns in your app. |
Batiyao.consume(purchaseId) | { consumed: true } | Marks one consumable as delivered. |
Batiyao.close() | {} | Closes your app. |
Batiyao.on(event, fn) / Batiyao.off(event, fn) | — | "visibility" → { visible } when the person leaves Batiyao and returns (pause your game). There is no event at start: assume visible. On the web the page's own visibilitychange fires too. "theme" → { theme }. |
Storage: keys match ^[A-Za-z0-9_.:-]{1,128}$; at most 1,024 keys per person per app; at most 120 writes a minute. It is kept on Batiyao's servers for this person in your app, on every device they use, and survives new versions of your app. It is not a place for secrets: the person's own devices can read it.
Timeouts: the ai calls wait as long as the work takes (the person can cancel). ready and getContext reject with TIMEOUT after 10 s; storage, getPurchases and consume after 20 s. pay, share, pickImage, pickVideo, getNickname({ ask: true }), multiplayer.host and multiplayer.join wait as long as the person takes; the other db and multiplayer calls time out after 20 s.
Errors: error.message is written for you, the developer, not for players. Show your own words to players and log the code.
6.1 The protocol (if you don't use the SDK file)
The SDK is a thin wrapper over postMessage to window.parent. Requests: { batiyao: 1, id: "<unique string, ≤ 64 chars>", method: "<method>", params: { … } }. Batiyao answers each request once with { batiyao: 1, id, result } or { batiyao: 1, id, error: { code, message } }, and sends events as { batiyao: 1, event, data }. Methods, their params and their result:
| method | params | result |
|---|---|---|
ready | {} | {} |
getContext | {} | { app_user_id, platform, locale, theme, version, test_mode, access, features?, capabilities? } (capabilities: { "ai.image": { version, state, tools } } for each one your manifest declares) |
pay | { product } | { status, purchase?, reason? } |
share | { image, text } (image a PNG/JPEG data: URL or "") | {} |
pickImage | { max_size? } | { status, image?, type?, width?, height? } (image a Blob) |
pickVideo | { max_seconds? } | { status, video?, type?, size?, duration?, width?, height? } (video a Blob) |
getNickname | { ask? } | { nickname } |
db.set | { key, value, replaceable?, rank? } | { saved } |
db.get | { key, player? } | { record } |
db.list | { key, order?, limit?, cursor? } | { records, next } |
db.remove / db.usage | { key } / {} | { removed: true } / { bytes, limit } |
mp.host | { max_players, public?, meta? } | { status, match? } |
mp.join | { code } or { match } | { status, match? } |
mp.list / mp.send / mp.leave | {} / { data, to? } / {} | { matches } / {} / {} |
getPurchases | {} | { purchases, access } |
consume | { purchase } | { consumed: true } |
storage.get | { key } | { value } (null if unset) |
storage.set | { key, value } | { saved: true } |
storage.remove | { key } | { removed: true } |
storage.keys | {} | { keys } |
ai.image.generate | { prompt, avoid?, quality?, seed? } | { status, image?, type?, width?, height?, seed? } (image a Blob) |
ai.audio.generate | { prompt, kind?, seconds?, seed? } | { status, audio?, type?, seconds?, seed? } (audio a Blob) |
ai.ask | { prompt, context?, web_search?, format?, max_chars? } | { status, text?, json?, searched? } |
ai.cancel | {} | {} |
close | {} | {} |
A request larger than 64 KB as JSON is dropped, except share, which may be up to 9 MB (it carries the picture). Answers to pickImage and pickVideo carry a Blob (messages are structured clones, not JSON). Events: nickname { nickname }; ai.progress { request, stage, done?, total? } (request is the id of your running ai request; stage is downloading, loading, making, searching or writing); and mp.joined { player }, mp.left { player, reason }, mp.message { from, data }, mp.host { player }, mp.closed { reason }. A method an older Batiyao doesn't know is answered with the error UNKNOWN_METHOD. Accept answers only from window.parent.
7. Payments
Payments are in BuckBuck, Batiyao's own points, which people earn on Batiyao.
7.1 How a payment works
- Your app calls
Batiyao.pay({ product: "golden_feathers" })from a tap or click: directly inside aclick,pointerup/touchendorkeydownhandler, without awaiting anything slow first (the browser's "user activation" lasts only a few seconds). This is checked on the web, and on phones wherever the web view can tell. - Batiyao shows its own payment sheet over your app: your app's name and icon, "Made by @you", the product's
statement, the total, the person's balance after, and a note that a third party made the app. Your app cannot draw, change or cover this sheet. While it is open your app keeps running (timers continue) but receives no input and novisibilityevent, so pause your game before callingpay(). There is no API to read prices: show the price from your own manifest; the sheet shows the authoritative one. - The person taps Pay or Cancel, and the Promise resolves.
| Result | Meaning | What to do |
|---|---|---|
{ status: "paid", purchase: { id, product, kind, purchased_at } } | Done. | Give the item (section 7.2). |
{ status: "cancelled" } | The person changed their mind. | Nothing. |
{ status: "pending" } | Batiyao is still confirming with the ledger. | Tell the person it will arrive. Check getPurchases() again on visibility: { visible: true } and at the next start; there is no event when it confirms. |
{ status: "failed", reason: "insufficient_balance" } | Not enough BuckBuck. Batiyao already told the person. | Nothing else. |
{ status: "failed", reason: "already_owned" } | A permanent product they already have. | Treat it as owned. |
{ status: "failed", reason: "unavailable" } | Batiyao couldn't take the payment (it already showed why). | Let them try later. |
pay() rejects (instead of resolving) only for mistakes in your code or a session that can't pay at all: NEEDS_TAP (not called from a tap or click), BUSY (a payment sheet is already open), NO_SUCH_PRODUCT, INVALID, LAUNCH_INVALID, NO_HOST, and on phones OFFLINE. Everything Batiyao shows the person itself (too little BuckBuck, payments unavailable, too many requests) resolves as failed.
7.2 Delivering what was bought
Your app has no server, so getPurchases() is the record. To never lose a purchase:
- At start-up, call
getPurchases(). For eachconsumablewithoutconsumed_at, if you haven't credited it yet: credit it in your state, save your state withBatiyao.storage.set, then callconsume(id). For eachpermanent, unlock it. - After
pay()resolvespaid, do the same forpurchase.id.
Keep a list of purchase ids you have credited (in storage) so a crash between "credit" and "consume" doesn't credit twice. A consumable nobody consumes within 24 hours is refunded to the person automatically. A refunded purchase disappears from getPurchases() (a refunded permanent item is no longer owned: lock it again). A test run sees only test purchases; a normal run only real ones.
7.3 Getting paid
You earn the full price. When someone buys something in your app, whether an unlock, a 30-day pass or an in-app item, the whole amount is yours. Batiyao currently keeps no share.
How a sale becomes your earnings
- The person pays. Their BuckBuck leaves their balance straight away, and the purchase shows in their BuckBuck history as a "Mini app" entry naming your app and the item.
- Batiyao holds the payment for 7 days. This protects buyers: if your app turns out to be broken or misleading, Batiyao can return their BuckBuck. It also protects you, because a sale that survives the hold is final.
- You are paid. When the 7 days are up, the payment moves to your own Batiyao account automatically, usually within a minute. Several sales that finish their hold together arrive as one payment, shown in your BuckBuck history as "Mini app sales".
Where to see your earnings. Your app's page (Mini apps → Built by me → your app) shows three figures: Purchases (sales so far), Held (BuckBuck waiting out its 7 days) and Paid to you (BuckBuck already in your balance).
What you need. Nothing beyond the Batiyao account you registered the app with. Earnings always go to that account. You don't open a separate seller account, add bank details or set anything up: every Batiyao account gets its BuckBuck wallet when it signs up.
When a sale doesn't pay out
- The buyer is refunded during the hold. A consumable item your app never marks as delivered (
consume()) within 24 hours goes back to the buyer automatically. So does any sale Batiyao refunds because of a problem with your app. - Your app is blocked. Payouts pause while an app is blocked. Held sales may be returned to buyers. Sales still on hold resume if the app is unblocked.
If a payout is late. If Batiyao can't complete a payment automatically, it doesn't try again blindly, so nobody can ever be paid twice. Instead a Batiyao administrator checks it and completes it by hand. Your earnings are never lost: they stay safely with Batiyao until they reach you. If "Held" hasn't moved to "Paid to you" a day after a sale's 7 days ended, contact Batiyao support at https://batiyao.com/support.
About BuckBuck. Earnings are paid in BuckBuck, Batiyao's own points. They work like any other BuckBuck in your balance, on everything Batiyao offers. They are not money and can't be exchanged for cash.
7.4 Paid access
access makes the app itself paid: one_time (an unlock, kept forever) or pass_30d (30 days; the person renews it themselves — nothing renews automatically). People buy access on the app's page before it opens; your app never needs to check or sell it. getContext().access.active is always true while your app runs.
7.5 Test runs
A version opened with Test on your app's page (or by a reviewing admin) is a test run: getContext().test_mode is true, the payment sheet says TEST, no BuckBuck moves, and test purchases are kept apart from real ones. Test any version, whatever its review state.
8. Sharing
Your app can offer the person something to share as a post, in their story, or in a chat: a picture it made (a score card, a drawing, a result) and suggested words. As with payments, Batiyao draws the sheet and the person decides. Your app never posts, and never learns what the person chose.
shareButton.addEventListener('click', function () {
// Straight from the click.
Batiyao.share({ image: scoreCanvas, text: 'I scored 42 in Tap Counter. Can you beat it?' })
.then(function () { /* the sheet closed; nothing else to do */ })
.catch(function (e) { console.warn('share', e.code); });
});
8.1 What happens
- Your app calls
Batiyao.share({ image, text })from a tap or click. - Batiyao checks the picture and re-draws it from its pixels. Nothing else in your file survives: no metadata, no location, no extra bytes. Then it opens its own sheet over your app, which is frozen meanwhile. The sheet shows the picture; a choice of Your story (free; the picture only, for the person's followers, for 48 hours), A post (costs the person the usual price of a post, currently 1 BuckBuck; the picture and words, for their posts' usual audience) or A chat (free; the picture and words as a message in one of their chats, which they pick on Batiyao's sheet); the words, which the person can change; and a note that your app made it.
- If the person shares, it is posted or sent as them, under every rule that applies to their own posts and messages (age, guardians, audience, who they may message, the post's price). Posts and stories are marked "via <your app> (mini app)". A post or chat message ends with a line linking back to your app,
Play <name>: https://batiyao.com/apps/<slug>, which chats show as your app's card with a Play button. That line is added by Batiyao, so don't put links in your text. share()resolves{}when the sheet closes. Your app is never told whether the person shared, where, or with whom. It never sees the person's chats. This is on purpose: it means nothing can reward or require sharing.
8.2 What you can send
| Field | Rule |
|---|---|
image | Optional, but a story needs one. A <canvas> (the SDK calls toDataURL('image/png')) or a PNG or JPEG data: URL, at most 6 MB, 64 to 4,096 pixels on each side. A PNG stays PNG unless the re-drawn file is over 5 MB; it then becomes a JPEG on a white background. Stories look best at 9:16 (720×1280 or 1080×1920). |
text | Optional. At most 380 characters, no control characters. No @ tags, # hashtags or links: those are refused (INVALID_TEXT). The person may add any of them themselves on the sheet. |
Send at least one of the two.
8.3 Rules
- Call it from a tap or click (
NEEDS_TAPotherwise). One Batiyao sheet at a time: a payment or a share (BUSYotherwise). - Never reward sharing, require it, or nag for it: no "share to unlock", no extra lives or coins for sharing, no repeated prompts. Apps that do are declined or blocked. It's the same rule the big platforms have, and the reason the outcome is hidden.
- Offer sharing at a natural moment, such as after a game or from a Share button the person chooses, not when the app starts.
- What you offer must reflect what the person did in your app. No personal information, and nothing misleading.
- A person can open at most 20 share sheets an hour. Past that, Batiyao tells them, and
share()still resolves{}.
Everything the person sees is handled by Batiyao: sharing being unavailable, too many shares, or the person not being able to post or send (too little BuckBuck for a post, a guardian's rule, a chat they can't write in, a missing date of birth). In all those cases share() still resolves {}. It rejects only for your own mistakes: INVALID, INVALID_IMAGE, IMAGE_TOO_LARGE, INVALID_TEXT, NEEDS_TAP, BUSY, LAUNCH_INVALID, NO_HOST.
8.4 Testing it
In a Test run, the sheet is marked TEST and nothing is posted. The simulator shows a simple version of the sheet and logs what the person chose (your app still hears only {}).
9. Photos and videos the person picks
Your app can ask the person for a photo or a video from their device. Batiyao opens its own picker and the person chooses. Your app gets only that one file, as a Blob, with everything but the picture and the sound removed. Your app can't open the device's files, camera or photo library by itself (section 2).
photoButton.addEventListener('click', function () {
// Straight from the click.
Batiyao.pickImage({ max_size: 1024 }).then(function (r) {
if (r.status !== 'picked') return; // 'cancelled' or 'unavailable'
return createImageBitmap(r.image).then(function (bitmap) {
ctx.drawImage(bitmap, 0, 0);
});
}).catch(function (e) { console.warn('pickImage', e.code); });
});
// Phone apps update on their own schedule: show the button only where the
// Batiyao your app runs in has the call.
Batiyao.supports('pickImage').then(function (yes) { photoButton.hidden = !yes; });
9.1 What happens
- Your app calls
Batiyao.pickImage()orBatiyao.pickVideo()from a tap or click. - Batiyao shows its own sheet over your app, which is frozen meanwhile: your app's name, that it asks for a photo (or video), and that only the one the person chooses goes to your app, without its details. The person presses Choose a photo and picks on the device's own picker, which may also offer the camera.
- Batiyao prepares the file (9.2). If it can't be used (too big, too long, a format Batiyao can't open), Batiyao tells the person and they can choose another. Your app isn't told.
- The call resolves with a
status:
status | Meaning |
|---|---|
picked | The person chose a file. It is in the result. |
cancelled | They closed the sheet without choosing. |
unavailable | This person can't give photos and videos to mini apps (a guardian's setting, 9.4). Batiyao has told them and offered to ask their guardian. Your app isn't told why. |
9.2 What you get
| Call | Option | picked result |
|---|---|---|
Batiyao.pickImage({ max_size }) | max_size: the longest side in pixels, a whole number from 64 to 4,096. Default 2,048. | { status, image, type, width, height }: image a Blob; type "image/png" or "image/jpeg"; the size in pixels. |
Batiyao.pickVideo({ max_seconds }) | max_seconds: the longest video you accept, from 1 to 600. Default 600. | { status, video, type, size, duration, width, height }: video a Blob; type "video/mp4", "video/quicktime" or "video/webm"; size in bytes; duration in seconds; the size in pixels. |
Photos are drawn again from their pixels, upright, and scaled down (never up) so the longer side is at most max_size. A PNG stays a PNG and keeps its transparency, unless it would be over 10 MB, when it becomes a JPEG; everything else becomes a JPEG on a white background. Nothing of the original file survives: no location, camera, time or other metadata. The person can choose pictures up to 50 MB in any format their device opens (JPEG, PNG, WebP, the first frame of a GIF, HEIC on iPhones).
Videos up to 100 MB, in MP4, MOV or WebM, are not re-encoded: the picture and sound are exactly as recorded. Batiyao removes the location, the camera details, the title and other descriptive metadata, and the recording time first.
Using the file: createImageBitmap(blob), blob.arrayBuffer(), and <img>, <video> or <audio> with URL.createObjectURL(blob) all work (blob: URLs are allowed; revoke them when you're done). There is nowhere to keep the file itself: Batiyao.storage holds text up to 4,096 characters, so keep what your app made from it. To let the person post or send something made from it, offer it with share() (section 8).
9.3 Rules
- Call it from a tap or click (
NEEDS_TAPotherwise). One Batiyao sheet at a time: a payment, a share or a picker (BUSYotherwise). - Ask only when the person does something that needs a photo or video, such as an "Add photo" button. Not when the app starts, and not again and again.
- What the person gives your app stays on their device: your app has no network, and nothing leaves it except through
share(), which the person decides. Don't suggest otherwise. <input type="file">, dragging files in and pasting files don't work in a mini app: no file chooser opens, and a file can't be read. Use these calls.- It rejects only for your own mistakes:
INVALID(an option out of range),NEEDS_TAP,BUSY,UNKNOWN_METHOD(a Batiyao app from before these calls; checksupports()first),NO_HOST.
9.4 Children
A guardian decides whether their child can give photos and videos to mini apps, separately from opening mini apps and from paying in them. It's off until the guardian turns it on. When it's off, the call resolves unavailable. Your app doesn't need to do anything else.
9.5 Testing it
A Test run works exactly as it will for people. The simulator shows a simple version of the sheet, prepares photos the same way (it passes videos through without removing anything; Batiyao does that), and can answer unavailable so you can try that path (see the simulator's README).
10. Nicknames and shared data
Your app can keep data that every player of your app can read: a champions list, a world record, the most-solved puzzles. Batiyao stores it (1 MB per app); you run no server. Players appear by a nickname they choose for your app on Batiyao's own sheet, never by their name.
// After a game: save this player's best score, replaceable so a full store
// drops the lowest scores first.
Batiyao.db.set('best', JSON.stringify({ score: 42 }), { replaceable: true, rank: 42 });
// The champions list.
Batiyao.db.list('best', { order: 'rank_desc', limit: 10 }).then(function (page) {
page.records.forEach(function (r) {
addRow(r.player.nickname || 'Player', JSON.parse(r.value).score, r.player.id === me);
});
});
10.1 Nicknames
Batiyao.getNickname() resolves { nickname }: this person's nickname in your app, or null if they haven't chosen one. With { ask: true } and no nickname yet, Batiyao shows its sheet first (call it from a tap; NEEDS_TAP otherwise) and resolves with what they chose, or null if they closed it. Only Batiyao asks and saves: your app can't set a nickname or draw that screen.
- Adults type one (2–20 characters, in any language and script) or pick one of Batiyao's suggestions. Batiyao refuses links,
@,#, numbers of 5 or more digits, names that pretend to be Batiyao, and slurs. - Children only pick from Batiyao's suggestions ("Blue Falcon 27"; in the person's language where Batiyao has a checked list), so they can't type a real name, school or number.
- People can change it any time from Batiyao's menu around your app; you get the event
nickname{ nickname }. An admin can replace a reported nickname.
A player as your app sees one: { id, nickname }, where id is the app_user_id from getContext().
10.2 The shared store
| Call | Resolves to | Notes |
|---|---|---|
Batiyao.db.set(key, value, { replaceable, rank }) | { saved: true } or { saved: false } | Writes your own record under key (one per player per key; writing again replaces it). value: text of at most 1,024 bytes (JSON.stringify objects). replaceable (default false): may Batiyao remove this record to make room when the store is full? rank: an optional number used for sorting and for what goes first. |
Batiyao.db.get(key, { player }) | a record, or null | Your own record under key, or another player's (player: their id). |
Batiyao.db.list(key, { order, limit, cursor }) | { records, next } | Every player's record under key. order: "rank_desc" (default), "rank_asc" or "recent"; records without a rank come last. limit 1–100 (default 20). Pass next back as cursor for the next page. |
Batiyao.db.remove(key) | true | Removes your own record. |
Batiyao.db.usage() | { bytes, limit } | How full the store is (limit is 1,048,576). |
A record: { player: { id, nickname }, key, value, rank, replaceable, updated_at }. Keys are 1–128 of A–Z a–z 0–9 _ . : -.
When the store is full (1 MB, counted as the bytes of each key and value), a write that doesn't fit makes room by removing records that were written as replaceable, in this order: records without a rank first, then the lowest rank, and among equals the oldest. The record being written takes part: if it would be the first to go (a score lower than everything on a full top board), it isn't saved and set resolves { saved: false }. A record written without replaceable is never removed by Batiyao; if nothing can make room, set rejects with STORE_FULL.
Writes are limited to 60 a minute per person, reads to 120 a minute (RATE_LIMITED). Test runs have their own store, so testing never touches your live board.
10.3 Rules
- Scores come from the player's device, so a determined player can cheat. Build for fun, not prizes.
- The shared store is for game data. Never use it to let players send each other messages or free text: that is chat, and a developer who enables chat is banned with all their apps (section 11.4).
- Don't put personal information in it. It is readable by every player of your app.
11. Multiplayer
Players of your app can play live, together: 2–8 people in a match, sending small messages through Batiyao. Your app still has no network; Batiyao relays.
Multiplayer is off until a Batiyao admin switches it on for your app. Ask on your app's page (batiyao.com/apps → Built by me → your app → Multiplayer), saying what players do together. An admin opens it to adults, or to adults and children, or not at all, and you get a notification.
Batiyao.supports('multiplayer').then(function (yes) { playOnlineButton.hidden = !yes; });
playOnlineButton.addEventListener('click', function () {
Batiyao.multiplayer.host({ max_players: 2, public: true, meta: { mode: 'race' } }).then(function (r) {
if (r.status !== 'joined') return; // 'cancelled' or 'unavailable'
showCode(r.match.code); // the other player joins with it
});
});
Batiyao.multiplayer.on('joined', function (e) { startRace(e.player); });
Batiyao.multiplayer.on('message', function (e) { moveCar(e.from, e.data); });
Batiyao.multiplayer.on('closed', function (e) { backToMenu(e.reason); });
function onTick() { Batiyao.multiplayer.send({ x: car.x, y: car.y }); }
11.1 Matches
| Call | Resolves to | Notes |
|---|---|---|
multiplayer.host({ max_players, public, meta }) | { status, match? } | Starts a match. max_players 2–8; public (default false): listed by list() for anyone to join; meta: a small object (≤ 256 bytes of JSON) shown in the list, such as a mode. From a tap. |
multiplayer.join({ code }) or join({ match }) | { status, match? } | Joins by the 6-character code someone shared, or by a match id from list() (public matches only). From a tap. |
multiplayer.list() | an array of { id, players, max_players, meta, host } | Open public matches of your app that this person can join (up to 50). |
multiplayer.send(data, { to }) | {} | Sends data (any JSON, ≤ 1,024 bytes) to everyone else in the match, or to one player (to: their id). No receipt. |
multiplayer.leave() | {} | Leaves the match. |
multiplayer.on(event, fn) / .off(event, fn) | — | "joined" { player }, "left" { player, reason }, "message" { from, data }, "host" { player } (the host left; this is the new one), "closed" { reason }. |
status is "joined" (with match), "cancelled" (the person closed Batiyao's nickname sheet) or "unavailable" (multiplayer isn't open to this person in your app; for a child, Batiyao has told them and offered to ask their guardian). A match: { id, code, max_players, public, meta, host, me, players }, where host and me are player ids and players is [{ id, nickname }]. Before someone's first match Batiyao makes sure they have a nickname.
Limits: 20 messages a second per player (short bursts to 40; extra messages are dropped, and a player sending far more is disconnected); one match at a time per person; 500 live matches per app (RATE_LIMITED); a match with one player and no messages for 10 minutes closes, and every match closes after 4 hours. closed reasons include "unavailable" (an admin switched multiplayer down, or the app was blocked), "idle", "expired" and "connection_lost". Nothing a player sends is stored.
11.2 Children
Where an admin has opened your app to children, a child can play only if their guardian has switched on Playing online in mini apps, and only ever with children of their own age group (13–15, 16–17); adults and children never meet in a match, whatever code is shared. Children appear by a Batiyao-suggested nickname. Your app doesn't need to do anything for this.
11.3 Testing it
In a Test run, multiplayer always works for you and Batiyao's admins, among yourselves: open the Test run in two browser windows to play against yourself (the second seat's id ends in ~2). Test matches never meet real players. The simulator runs matches across browser tabs and can answer unavailable (see its README).
11.4 Rules
- A game must never let players chat or send each other messages or free text, through multiplayer, the shared store or anything else. Players can report a player ("Uses the game to chat or send messages"), and people can report your app. A developer found enabling chat is banned: every one of their apps is blocked and they can't publish again.
- Send only game state (positions, moves, scores). Show other players only by their Batiyao nickname.
- Text fields that let players type to each other count as chat.
12. Batiyao's AI: pictures, sounds and answers
Your app can ask Batiyao's own AI, which runs on the person's phone, to make things for it:
| Capability | Batiyao feature | What it does | Version 1 |
|---|---|---|---|
ai.image | BGen | A picture from words. | 384 × 384 PNG; about 40 s (fast) to a minute (best) on a phone. |
ai.audio | BAudio | Music or a sound effect from words, without singing. | WAV; music 8, 15 or 30 s, sounds 3, 6 or 12 s; a few seconds to make. |
ai.assistant | Batiyao AI | An answer in words (or JSON), optionally after searching the web. | Up to 1,500 characters; a few seconds (about a minute the very first time, while the model loads). |
Nothing your app asks for leaves the phone, except Batiyao AI's web searches, which go through Batiyao's own search (always safe-search, and an app's results never carry links). Batiyao AI in your app can't act for the person: it can't post, message, pay or read anything of theirs; it only answers.
12.1 Asking for it
Declare each capability in manifest.json, with the version you need and your reason in one line (10–140 characters, shown to people as you wrote it):
"capabilities": {
"ai.image": { "version": 1, "purpose": "Draws a card for each level from the words you choose." },
"ai.assistant": { "version": 1, "purpose": "Writes the quiz questions for each round.", "tools": ["web_search"] }
},
"requires": ["ai.assistant"]
tools: onlyai.assistanttakes one,web_search. Without it Batiyao AI answers from what it knows.requires: put a capability here only if your app is useless without it (section 12.4).- The first version that asks for a capability — or for a higher version, or a new tool — waits for an admin, who grants or declines each one (a declined optional capability just stays off; a required one can't be declined, the version is declined instead). Later versions that ask for the same go through review as usual. An admin can turn a capability off for your app at any time.
- Your app's page shows what you were granted, with your purpose, in a "Uses Batiyao's AI" box, and the consent screen mentions it.
- Test runs can use everything your version asks for before anyone has decided, so you can build with it.
12.2 Calling it
const c = await Batiyao.capability('ai.image');
// { supported, version, state: 'ready' | 'needs_download' | 'unavailable', tools }
if (c.state !== 'unavailable') drawButton.hidden = false;
drawButton.onclick = async () => { // a tap: required for pictures and sounds
const r = await Batiyao.ai.image.generate({
prompt: 'a paper kite over green hills, watercolour',
quality: 'fast', // 'fast' | 'balanced' | 'best'
onProgress: (p) => showProgress(p.done, p.total),
});
if (r.status === 'made') img.src = URL.createObjectURL(r.image);
};
| Call | Resolves to | Notes |
|---|---|---|
Batiyao.capability(name) | { supported, version, state, tools } | Never rejects. state is ready, needs_download (Batiyao will ask the person to download the model the first time; it's 1–2.5 GB) or unavailable. Batiyao never says why something is unavailable. |
Batiyao.ai.image.generate({ prompt, avoid, quality, seed, onProgress }) | { status: 'made', image: Blob, type: 'image/png', width, height, seed } | prompt 1–400 characters; avoid up to 200 (what to leave out); the same seed and words make the same picture. From a tap. |
Batiyao.ai.audio.generate({ prompt, kind, seconds, seed, onProgress }) | { status: 'made', audio: Blob, type: 'audio/wav', seconds, seed } | kind music (8/15/30 s, default 15) or sound (3/6/12 s, default 6). From a tap. |
Batiyao.ai.ask({ prompt, context, web_search, format, max_chars, onProgress }) | { status: 'answered', text, json, searched } | prompt 1–2,000 characters; context up to 8,000 (text to answer from: your game's rules, a level's words); web_search: true needs the tool; format: 'json' makes it answer with JSON only, parsed into json (null if it couldn't); max_chars 50–1,500 (default 800). Needn't come from a tap. |
Batiyao.ai.cancel() | {} | Stops your app's running request (it answers cancelled). |
Every answer has a status: made / answered; cancelled (the person stopped it, or left your app); unavailable (not here or not for this person — carry on without it); blocked (Batiyao wouldn't make that: ask for something else). One AI request runs at a time per app (BUSY otherwise). Batiyao shows its own strip over your app while it works, with your words and a Cancel button; the first time, it asks the person whether your app may use it.
12.3 Rules
- Every picture and sound passes Batiyao's safety checks, and some words are refused (
blocked). A picture is never made with nudity, sex or gore, whatever the prompt; there's no way to switch that off. Batiyao AI gives no links, addresses or emails in its answers. - Results are yours to show and use in your app. They aren't saved to the person's phone, uploaded or shared by Batiyao;
share()works on a picture you made like on any other. - Limits per person per app: about 30 pictures, 60 sounds and 60 answers an hour, and 30 web searches. Past them, calls answer
unavailablefor a while. - Children: a guardian switch ("Batiyao's AI in mini apps") must be on, as well as your app being allowed. Batiyao handles it; your app just sees
unavailable. - Don't ask the person to type personal details into a prompt, and don't use answers as facts about real people.
12.4 Where it works, and versions
These capabilities run in the Batiyao phone app on phones that can run the models (most phones from the last few years with 4–5 GB of memory). On batiyao.com and on phones that can't, capability() says unavailable and every call answers { status: 'unavailable' }, so check before you show the button.
If your app is pointless without one, put it in requires. Then Batiyao never opens your app where it can't work: on the website the person is told to open it in the Batiyao app, on an older phone app to update Batiyao, and on a phone that can't run the model, that this phone can't. Your app page shows this upfront.
Each capability has a version (version in the manifest is the lowest you need). New versions only ever add options and answers; anything that worked keeps working, so a newer Batiyao always runs an app written for an older version.
12.5 Testing it
The simulator answers with stand-ins — a picture with your words on it, a tone, a canned answer — so you can build the flow; its switch tries needs_download and unavailable. On Batiyao, a Test run uses the real thing on a phone (Built by me → your app → Test, in the Batiyao app).
13. Ages and who can use your app
| Viewer | Sees apps rated up to |
|---|---|
| Adults (18+) | 18+ |
| 16–17 | 16+ |
| Under 16, people who haven't given a date of birth, and signed-out visitors | 13+ |
Children also need a guardian to switch mini apps on and to allow your app specifically; paying, giving your app photos and videos, playing online and using Batiyao's AI each need a separate guardian switch. You don't need to handle any of this: Batiyao does it before your app opens. Your app never learns the person's age, name, username, contacts or location.
14. Review, versions and limits
- Every version is reviewed. An admin can approve, decline (with a reason you see) or hold it. With no admin action within 1 hour, a version whose checks raised no flag (section 5.3) is approved automatically.
- An approved version goes live and replaces the previous one. You get a Batiyao notification when a version is approved, declined, or your app is blocked/unblocked.
- You can withdraw a version that is still waiting.
- You can delete your app (Built by me → your app → Delete app, typing its name to confirm). It leaves Mini apps for everyone at once, every version's files are removed for good, and it no longer counts toward your 5 apps, so you can start another. It can't be undone. Payments people made in the last 7 days that haven't reached you yet go back to them; earlier ones stay yours. Its App ID, address and history stay listed for you as deleted, and nobody else can take its address. A blocked app can't be deleted.
- People can report your app from Batiyao's menu around it; an admin can block an app at any time. A blocked app can't be opened, sold or updated.
- Limits: 5 apps per account (an app counts once a version of it is uploaded; versions don't count; a deleted app doesn't count). To publish more, your account must be a Batiyao partner, which Batiyao arranges; partners can publish up to 20. 10 uploads per app per day.
- A version that asks for Batiyao's AI it hasn't been granted waits for an admin (section 12.1).
Declined or blocked for: a statement that doesn't say what the person gets; pressuring people to pay (fake timers, payment prompts nobody tapped for); rewarding, requiring or nagging for sharing; asking for photos or videos the app doesn't need, or before the person asks for something that needs one; letting players chat or message each other (that bans the developer and blocks all their apps); content above the declared age rating; pretending to be Batiyao or someone else; trying to reach the network; collecting personal information.
15. Testing tools
- Simulator (your machine, no account needed):
git clone https://github.com/batiyao-app/developer-sample && cd developer-sample && npm run miniapp -- path/to/your/folder, then open http://localhost:3100. Same sandbox and CSP, a payment sheet drawn from your manifest, saved data and purchases kept in the simulator, stand-ins for Batiyao's AI (a picture with your words on it, a tone, a canned answer; its switch triesneeds_downloadandunavailable), and a log of every SDK call. It's a convenience: the real check is a Test run on Batiyao. - Packer:
npm run pack:miniapp -- path/to/folder --app-id ma_abcdefgh23 --out distchecks the rules in sections 3–5 and writesdist/<folder>-<version>.zipwith a correctmanifest.jsonat the top. Node 18+, no dependencies. - Zipping by hand: zip the contents of the folder, without hidden files:
cd folder && zip -r ../app.zip . -x '.*' -x '*/.*'.
16. Error codes
Codes an SDK call can reject with (error.code):
| Code | Meaning |
|---|---|
NO_HOST | Not running inside Batiyao or the simulator. |
TIMEOUT | Batiyao didn't answer in time (section 6). |
INVALID | Bad parameters (e.g. pay() without a product id, a non-string stored value, a max_size out of range). |
INVALID_KEY | A storage key that doesn't match the pattern. |
VALUE_TOO_LONG | A stored value over 4,096 characters. |
STORAGE_FULL | Over 1,024 keys. |
RATE_LIMITED | Too many writes or payment requests; wait a moment. |
NEEDS_TAP | pay(), share(), pickImage(), pickVideo(), getNickname({ ask: true }), multiplayer.host(), multiplayer.join(), ai.image.generate() or ai.audio.generate() wasn't called from a tap or click. |
BUSY | A Batiyao sheet (payment, share, picker or nickname) is already open, or one of your AI requests is still running. |
STORE_FULL | The shared store is full (1 MB) and nothing in it may be replaced (section 10.2). |
MATCH_FULL | That match has as many players as it allows. |
NO_SUCH_MATCH | No open match with that code or id for this person. |
ALREADY_IN_MATCH | Leave the current match first. |
NOT_IN_MATCH | send() without a match. |
MESSAGE_TOO_LARGE | A multiplayer message over 1,024 bytes of JSON. |
INVALID_IMAGE | The image passed to share() isn't a readable PNG or JPEG of 64–4,096 px a side. |
IMAGE_TOO_LARGE | The image passed to share() is over 6 MB. |
INVALID_TEXT | share() text is over 380 characters, or has an @ tag, a # hashtag, a link or a control character. |
NO_SUCH_PRODUCT | The product id isn't in the running version's manifest. |
NOT_CONSUMABLE | Already consumed, or not a consumable. |
LAUNCH_INVALID | The session ended (e.g. after 12 hours); the person reopens the app. |
MINI_APP_UNAVAILABLE | The app was blocked while running. |
MINI_APP_DELETED | The app was deleted by its developer while running. |
OFFLINE | (Phones) no connection for a call that needs Batiyao's servers. |
UNKNOWN_METHOD | A method this version of Batiyao doesn't know. |
NOT_DECLARED | An ai call for a capability (or the web_search tool) that your manifest doesn't declare. |
Upload problems are listed all at once on your app's page, in plain sentences, each naming the file and what to change.
17. A complete minimal app (copy and adapt)
manifest.json:
{
"batiyao_miniapp": 1,
"app_id": "ma_abcdefgh23",
"version": "1.0.0",
"name": "Tap Counter",
"summary": "Count your taps, keep your best, buy a golden theme.",
"category": "games",
"age_rating": "4+",
"icon": "icon.png",
"orientation": "portrait",
"access": { "type": "free" },
"products": [
{ "id": "gold_theme", "title": "Golden theme",
"statement": "A golden colour theme for Tap Counter, yours to keep.",
"price": "0.5", "kind": "permanent" }
]
}
index.html:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>Tap Counter</title>
<style>
html, body { height: 100%; margin: 0; font-family: system-ui, sans-serif; }
body { display: grid; place-items: center; background: #eef2ff; }
body.gold { background: #fde68a; }
button { font: inherit; padding: 12px 20px; border-radius: 12px; border: 0; }
#tap { font-size: 48px; width: 200px; height: 200px; border-radius: 50%; background: #4f46e5; color: #fff; }
</style>
</head>
<body>
<main style="text-align:center">
<p>Best: <span id="best">0</span></p>
<button id="tap">0</button>
<p><button id="buy">Golden theme · 0.5 BuckBuck</button></p>
<p id="msg" role="status"></p>
</main>
<script src="batiyao-miniapp.js"></script>
<script src="app.js"></script>
</body>
</html>
app.js:
(function () {
'use strict';
var count = 0, best = 0;
var $ = function (id) { return document.getElementById(id); };
// Something is on screen already: tell Batiyao now, load the rest after.
Batiyao.ready().catch(function () {});
function applyPurchases() {
return Batiyao.getPurchases().then(function (r) {
var gold = r.purchases.some(function (p) { return p.product === 'gold_theme'; });
document.body.classList.toggle('gold', gold);
$('buy').hidden = gold;
}).catch(function (e) { console.warn('getPurchases', e.code); });
}
$('tap').addEventListener('click', function () {
count += 1;
$('tap').textContent = count;
if (count > best) {
best = count;
$('best').textContent = best;
Batiyao.storage.set('best', String(best)).catch(function (e) { console.warn('save', e.code); });
}
});
$('buy').addEventListener('click', function () {
// Straight from the click: Batiyao shows its own payment sheet.
Batiyao.pay({ product: 'gold_theme' }).then(function (r) {
if (r.status === 'paid' || r.reason === 'already_owned') return applyPurchases();
if (r.status === 'pending') $('msg').textContent = 'Confirming your payment…';
}).catch(function (e) {
console.warn('pay', e.code);
$('msg').textContent = 'That didn’t work. Please try again.';
});
});
Batiyao.on('visibility', function (d) {
// Pause here when d.visible is false. On return, pick up any payment
// that was still being confirmed.
if (d.visible) applyPurchases();
});
// Each step on its own, so one failing doesn't skip the other.
Batiyao.storage.get('best')
.then(function (v) { best = Number(v) || 0; $('best').textContent = best; })
.catch(function (e) { console.warn('load', e.code); });
applyPurchases();
})();
Plus icon.png (square, 128–1024 px) and batiyao-miniapp.js from the SDK.
18. Checklist (for people and AI agents)
- ☐
manifest.jsonat the top of the zip;app_idis the registered App ID;versionhigher than the last upload. - ☐ Category chosen on the app's page (or a valid
categoryin the manifest). - ☐ Every file referenced by a relative path; nothing from the internet; no paths starting with
/. - ☐ No
<iframe>,<object>,<embed>,<base>, meta refresh or resource hints; no remoteurl()/@import. - ☐ No
localStorage/cookies/IndexedDB —Batiyao.storageinstead; noalert/confirm/prompt. - ☐ Workers only from
blob:URLs; no WebSocket, WebRTC, EventSource orsendBeacon. - ☐
Batiyao.ready()called as soon as something is on screen (not after loading storage). - ☐
pay()only from a tap or click; purchases re-checked withgetPurchases()at start-up; consumables credited, saved, then consumed. - ☐ Each product's
statementsays plainly what the person gets; prices 0.1–20 (access 0.1–50). - ☐
share()only from a tap, with no reward for sharing and nothing locked behind it; suggested text has no@,#or links. - ☐ Nicknames read with
getNickname()(never typed in your app); shared data holds game data only, no messages or personal information. - ☐ Multiplayer requested on the app's page; buttons hidden when
supports('multiplayer')is false; only game state sent; no way for players to chat. - ☐ Photos and videos only through
pickImage()/pickVideo()(no<input type="file">), from a tap on something that needs one; its button hidden whensupports()says no;cancelledandunavailablehandled. - ☐ Batiyao's AI declared in
capabilitieswith an honest purpose; its buttons hidden whencapability()saysunavailable; everystatushandled;requiresonly for what the app can't work without. - ☐ Icon is a square PNG of 128–1024 px; file names only letters, digits,
.,_,-. - ☐ Honest
age_rating; no personal data collected; the app pauses onvisibility: false. - ☐ Ran in the simulator, packed with the packer, and passed a Test run on Batiyao.