# Build apps for Lia Source: https://docs.lialabs.org/
Lia SDK 1.6.0

Build apps for Lia. We bring the people.

Your web app opens inside the Lia app, in front of people who already use it every day. Add one script and every player arrives signed in, in their own language and theme, with $LIA payments, invites and the rest already built.

Start buildingSDK reference
## What a Lia app is A Lia app is a web page you build and host yourself. People open it from the Apps Center in the Lia app, and it runs inside Lia, much like a Telegram mini app. Your page loads the Lia SDK from Lia, and the SDK is how it talks to Lia: who the player is, the pay sheet, the friend picker, notifications, ads, storage and the phone itself. Your app can also have its own server. It calls Lia's server API with your secret key to check what the browser tells it: that a session is real, that a charge was paid, that a video was watched. Nothing important ever trusts the browser. ```html ``` ## Start here
QuickstartA working app in Lia in a few minutes: the script, init and ready. Getting accessHow your app is added to Lia, and how you get your keys today. How apps run in LiaThe frame, the three looks, the header and its menu, and the rules. npm packagesTyped SDK with hooks for React, Vue, Svelte, Solid, Angular and more. Sell an itemLia's pay sheet, then confirm and deliver on your server.
## Everything in the SDK
Players and sessionsEvery player arrives signed in, with a session your server can verify. PaymentsPeople pay in $LIA on Lia's own sheet, at the price in Lia's records. Reward poolsPay players in $LIA from a pool the Lia team funds for your app. InvitesA friend picker and links that open your app in the right room. NotificationsOnly for people who allow them, with limits so nobody is spammed. AdsRewarded and between round videos, played and paced by Lia. Leaderboards and storageBest scores, cloud saves, storage on the phone and in its Keychain. Look and feelLia's theme, 13 languages, safe areas, buttons, haptics and sound. The phoneMotion sensors, Face ID, the QR scanner, the clipboard and links.
## How the pieces fit
  1. The person opens your app in LiaLia opens your page in its frame and signs the person in for you, as a player of your app.
  2. Your page calls the SDKLia.init() gives you the player, a session token, their language and theme. Everything else is a call away.
  3. Lia does the sensitive partsPayments, ads, invites and permissions happen in Lia's own screens over your app, never in your page.
  4. Your server checksWith your secret key, your server verifies sessions, charges and watched videos, and pays rewards from your pool.
## Addresses | What | Where | | --- | --- | | The SDK | `https://api.lialabs.org/sdk/lia-apps-v1.js` | | The server API | `https://api.lialabs.org/api/apps/v1` | | The SDK demo (opens inside Lia) | `https://api.lialabs.org/sdk/demo` | | Invite links | `https://api.lialabs.org/play/` | | These docs as Markdown, for AI tools | [`/llms.txt`](/llms.txt) and [`/llms-full.txt`](/llms-full.txt) | > **Note** In Q1 2027 you'll sign up to the Lia developers dashboard to add your app and get its keys yourself. Until it opens, the Lia team sets your app up and gives you its keys. See [Getting access](/access). # Quickstart Source: https://docs.lialabs.org/quickstart ## What you need - **A web page on https.** Any framework, hosted anywhere, as long as it's served over https. Lia only opens https pages. - **A Lia account with a username.** People need a username to open apps, and so do you. - **Your app added to Lia.** The Lia team adds it with your page's address and makes you its developer, so you can open it while it's in test. See [Getting access](/access). ## 1. Load the SDK Load the SDK from Lia, not from a copy. Lia serves it so that fixes reach every app at once. ```html title="index.html"
Opening…
``` The SDK puts one object on the page, `window.Lia`. Outside the Lia app it still loads, but every call fails with the code `not_in_lia`, so you can show a friendly message in a normal browser. ## 2. Say hello Call `Lia.init()` first. It answers with the player, a session token for your server, the person's language and theme, and more. When your page is drawn, call `Lia.ready()`: until then, Lia shows its own loader over your app. ```js title="app.js" Lia.init().then( function (me) { // me.player.id is this person's id in your app; me.player.name is their Lia username. document.getElementById("app").textContent = "Hello, @" + me.player.name; Lia.ready(); }, function (error) { document.getElementById("app").textContent = error.code === "not_in_lia" ? "Open this app inside Lia." : "Something went wrong: " + error.code; } ); ``` If you prefer `async` and `await`: ```js try { const me = await Lia.init(); render(me); Lia.ready(); } catch (error) { if (error.code === "not_in_lia") showOpenInLia(); } ``` ## 3. Open it in Lia While your app is in test, only you and the testers the Lia team added can see it. Open the Lia app, go to the Apps Center, and open your app. Its header says **Test** next to your name, and anything it sells costs pretend $LIA. > **Tip** Lia only opens https addresses. To try a page running on your computer, put it behind an https tunnel (for example `cloudflared tunnel --url http://localhost:5173`) and ask the Lia team to point your test app at the tunnel's address. ## 4. Try something A few things to try once `init()` has answered: ```js // A toast in Lia's own style. Lia.toast("Welcome in"); // A tap you can feel. Lia.haptic("success"); // Lia's main button under your app. Lia.mainButton.setText("Start").show().onClick(function () { Lia.mainButton.showProgress(); startGame().finally(function () { Lia.mainButton.hide(); }); }); // Keep a best score and see the top 50. Lia.submitScore(1280).then(function () { return Lia.leaderboard(); }).then(console.log); ``` ## 5. Add your server For anything that matters (items people paid for, $LIA you pay out) your server has to check what the page tells it. It gets a session token from the page and verifies it with your secret key: ```js title="server.js" const res = await fetch("https://api.lialabs.org/api/apps/v1/session/verify", { method: "POST", headers: { Authorization: `Bearer ${process.env.LIA_KEY}`, "Content-Type": "application/json" }, body: JSON.stringify({ session }), }); const { valid, playerId } = await res.json(); ``` [Verify on your server](/guides/verify-on-your-server) goes through it step by step. ## Next - [How apps run in Lia](/concepts/how-apps-run): the frame, the three looks and the rules your page runs under. - [Sell an item](/guides/sell-an-item): Lia's pay sheet, end to end. - [SDK reference](/reference/sdk): every method, property and event. - The SDK demo at `https://api.lialabs.org/sdk/demo` tries every call. It opens inside Lia for testers the Lia team adds to it. # Getting access Source: https://docs.lialabs.org/access > **Note** In Q1 2027 you'll sign up to the Lia developers dashboard to add your app and get its keys yourself. Until it opens, the Lia team sets your app up and gives you its keys, as below. ## How to get started
  1. Talk to the Lia teamTell us what you're building, from the Builders page. Include your Lia username and your app's https address.
  2. You become a developerThe team makes your Lia account a developer, with the name shown under your apps.
  3. Your app is added, in testThe team adds your app in test mode, with you and your testers able to open it.
  4. You get your keysA test key, a live key and a webhook signing secret, shown once.
  5. You go liveWhen you're ready, the team switches your app to live and it shows in everyone's Apps Center.
## What the Lia team sets up Everything about your app lives in Lia's records, not in your page. Ask the team for any of these, and to change them later. | Setting | What it is | | --- | --- | | App id | Its public identifier, in your invite links and webhooks. 2 to 40 characters: lowercase letters, digits and `-`, starting with a letter or digit. It doesn't change. | | Name and tagline | Up to 40 and 80 characters, shown in the Apps Center and on Lia's pay sheet. | | Address | Your page's https address. Lia only loads pages from this origin. | | Icon and banner | https images for the Apps Center and its featured card. | | Look | `sheet`, `full` or `fullscreen`: how your app opens. See [How apps run in Lia](/concepts/how-apps-run#three-looks). | | Category | One of the Apps Center's categories (games, utilities and others). | | Status | `test`, `live` or `paused`. See [Test and live](/concepts/test-and-live). | | Testers | Lia usernames who can open your app while it's in test. | | Products | What your app sells: an id, a name and a price in $LIA. See [Payments](/concepts/payments#products). | | Your share | The part of each sale held for you by Lia Labs. 70% unless agreed otherwise. | | Webhook address | Where Lia sends signed webhooks (https). See [Webhooks](/reference/webhooks). | | Reward pool | $LIA the Lia team funds for your app to pay players, and the most one player can get from it a day. See [Reward pools](/concepts/reward-pools). | | Notifications a day | How many notifications your app may send one person a day. 3 unless changed; 0 turns them off. | | Minimum streak | A check-in streak people need before they can open your app (0 for none). | ## Your keys Each app has three secrets. The team makes them together and they're shown once: Lia keeps only a hash of each key, so nobody can show them to you again. | Secret | Looks like | Use | | --- | --- | --- | | Test key | `lia_test_…` | Your server's key while you build. Only sees test charges, test players and test mode. | | Live key | `lia_live_…` | Your server's key in production. Works only while your app is live. | | Webhook signing secret | `whsec_…` | Checks that a webhook really came from Lia. | Your **app id** is public and safe to put in your page. The three secrets are not: keep them on your server only, never in your page, your app's code on GitHub or a message. > **Important** Making new keys replaces all three at once: the old test key, live key and webhook secret stop working that moment. If a key leaks, ask the Lia team for new ones, then update your server straight away. ## Getting paid - **Your share of sales.** Lia Labs holds your share of each sale for you (70% unless agreed otherwise) and pays it out **monthly, into your own Lia balance**. - **Ad earnings.** You get **50% of what the ads in your app earn**. Lia counts every ad your app shows and what it earns, and pays your part monthly with your share of sales. - **Withdrawing.** From $LIA mainnet in Q1 2028, when payments move on chain, you can withdraw your balance to your own wallet. ## Review The Lia team reviews every app before it goes live. Test it well with your testers first; [Testing your app](/guides/testing) has a checklist. # Build with AI Source: https://docs.lialabs.org/ai These docs are written to be read by people and by AI tools alike. Point your assistant (Claude, ChatGPT, Cursor, Copilot, Claude Code or any other) at the files below, and add the rules file to your project, so it writes Lia code the right way first time. ## For your assistant | What | Where | Use it for | | --- | --- | --- | | A map of the docs | [`/llms.txt`](/llms.txt) | The essentials and every page, with links to its Markdown. Start here. | | All of the docs in one file | [`/llms-full.txt`](/llms-full.txt) | Paste or attach it when your tool can't browse. | | Any page as Markdown | Add `.md` to its address, like [`/concepts/payments.md`](/concepts/payments.md) | One topic at a time. Every page's **Copy page** menu does this too. | | The server API as OpenAPI 3.1 | [`/openapi.json`](/openapi.json) | Generating a typed client, or letting an agent call the API correctly. | | A rules file for your project | [`/AGENTS.md`](/AGENTS.md) | Below: the rules that matter most, in a file assistants read. | | TypeScript types | `npm install @lia-labs/sdk` | Every method's params and answers, every event's data. | ## Copy page Every page has a **Copy page** menu next to its title: - **Copy as Markdown** puts the page on your clipboard, ready to paste into a chat. - **View as Markdown** opens its Markdown. - **Open in Claude** and **Open in ChatGPT** start a chat that reads the page. ## Add the rules to your project Save this as `AGENTS.md` in your project (or `CLAUDE.md` for Claude Code, or a rule in `.cursor/rules/`). Assistants read it before they write code, so they follow Lia's rules without being told each time. It's also at [`/AGENTS.md`](/AGENTS.md). ```text title="AGENTS.md" # Building a Lia app This is a web app that runs inside the Lia app (lialabs.org), like a Telegram mini app. Docs: https://docs.lialabs.org (every page as Markdown: add .md; everything: https://docs.lialabs.org/llms-full.txt). ## The SDK - In a page without a build step: gives window.Lia. Never copy or bundle that file. - With a bundler: npm install @lia-labs/sdk (or @lia-labs/sdk-react, -preact, -vue, -svelte, -solid, -angular, @lia-labs/elements). Use one or the other on a page, never both. - Call init() first, then ready() once the first screen is drawn. Outside Lia every call fails with code "not_in_lia"; for local development call mockLia() when isLia() is false. - Every failure is an Error with a code. "cancelled" from pay() is a normal no, not an error to report. ## Trust - Never trust the page. Your server verifies a session token (from init() or session(), 15 minutes) with POST https://api.lialabs.org/api/apps/v1/session/verify and takes the player id from Lia's answer, never from the page. - Secret keys (lia_test_..., lia_live_...) and the webhook secret (whsec_...) live on the server only. The app id is the only public identifier. - Prices come from Lia's records: the page passes a product id to pay(), never an amount. ## Payments - pay(productId) opens Lia's pay sheet and answers { chargeId, productId }. The server then checks GET /charges/:id (its player, product and status), gives the item once per charge id, and calls POST /charges/:id/deliver. If it can't deliver, POST /charges/:id/refund. - On opening, call charges() and deliver any paid, undelivered charges; listen with onCharge() too. Webhooks (charge.paid) are a nudge: check with the API, and verify the Lia-Signature header (HMAC SHA-256 of "." with the whsec_ secret). ## Rewards, ads, notifications - Pay players in $LIA only from the server: POST /rewards with a key that names the reason (a key pays once, ever). At most 10,000 LIA a reward; handle pool_empty and daily_limit. - A rewarded video answers { watched, adId }: give the reward only after the server checks POST /ads/verify (watched, and the same player), once per adId. - Notifications are opt-in (requestNotifications()), sent by the server with POST /notifications, at most 3 a day per person from one app, an hour apart, 180 characters, no links. Use a startParam to open the right screen. ## Feel like Lia - Use Lia's theme (CSS variables --lia-bg-color, --lia-text-color, --lia-hint-color...), the safe areas (--lia-safe-area-inset-bottom), Lia.language (13 languages) and Lia.rtl for dir. - Use Lia's mainButton for the main action, backButton for your own screens, Lia's dialogs and toasts, and play sound only while soundOn. - Only your own pages load in the frame: open other sites with openLink(url). ## Conventions - In text people read: "$LIA" for the token, "LIA" next to numbers (5 LIA). - Retry server POSTs with the same Idempotency-Key header. ``` ## Ask good questions A few prompts that work well with the files above: - "Read https://docs.lialabs.org/llms.txt, then build a Lia app in React where players buy extra lives for 5 LIA, with a Node server that verifies and delivers the charge." - "Using https://docs.lialabs.org/openapi.json, write a typed client for the Lia server API in TypeScript with retries and Idempotency-Key headers." - "Read https://docs.lialabs.org/guides/multiplayer-with-invites.md and add public tables to my two player game." ## If something's wrong AI tools can still get things wrong. The [SDK reference](/reference/sdk) and [server API](/reference/server-api) are the source of truth, and [Testing your app](/guides/testing) shows how to check everything before you go live. # How apps run in Lia Source: https://docs.lialabs.org/concepts/how-apps-run ## The frame When someone opens your app, Lia asks its server whether they may (see [Test and live](/concepts/test-and-live)), signs them in as a player of your app, and loads your page in a frame inside the Lia app. Your page is a normal web page: any framework, any host, served over https. The frame is locked down so people can trust every app in Lia: - **Only your own pages load.** The frame stays on your app's origin (its scheme, host and port). A link or redirect to any other site is blocked. Open other sites with [`Lia.openLink`](/reference/sdk-device#lia-openlink). - **Your page can still fetch.** Scripts, images, fonts and `fetch()` calls to other addresses work as on the web (with CORS, as usual). - **No popups or new windows**, no camera, microphone or location, and no payments of your own. Money, ads and anything on the phone happen in Lia's own screens over your app. - **Lia only hears your page in the top frame.** SDK calls from an iframe inside your page, or from a page on another origin, are ignored. - **Messages are small and paced.** A call over 16,000 characters is ignored (and times out). More than 20 calls in one second are refused with `slow_down`. Lia's frame reports `LiaApps/1` at the end of its user agent, if you want to recognise it on your server. ## The bridge The SDK talks to Lia through the frame's bridge. Each call your page makes is a message with an id; Lia answers it once, and the SDK turns that into a promise. Lia also sends events (the theme changed, the app paused) that you listen to with [`Lia.on`](/reference/sdk-lifecycle#lia-on). Calls that wait on the person (the pay sheet, an ad, the scanner, Face ID, a dialog) have no time limit. Every other call fails with `timeout` if Lia hasn't answered within 20 seconds. Outside the Lia app there is no bridge, and every call fails with `not_in_lia`. > **Note** Load the SDK from `https://api.lialabs.org/sdk/lia-apps-v1.js`, never a copy. Lia serves it so that fixes reach every app; it's cached for a minute. ## Three looks Each app opens in one of three looks, set by the Lia team for your app. Your page can switch between them with the SDK. | Look | What it is | | --- | --- | | `sheet` | Over Lia like a Telegram mini app, about four fifths of the screen high. The person can pull it up to full height, or your page calls [`Lia.expand()`](/reference/sdk-layout#lia-expand). A swipe down closes it. Most apps open this way. | | `full` | The whole screen, under Lia's header. | | `fullscreen` | Edge to edge, with only Lia's floating Close and ⋯ buttons over your page. Keep your content below [`Lia.contentSafeArea.top`](/concepts/look-and-feel#safe-areas). | `Lia.display` says how your app opened, and `Lia.isFullscreen` whether it's edge to edge right now. [`Lia.requestFullscreen()`](/reference/sdk-layout#lia-requestfullscreen) and `Lia.exitFullscreen()` switch to and from fullscreen. ## Lia's header and menu In `sheet` and `full`, Lia draws a header over your page: your app's icon and name (and **Test** with your developer name while in test), a ⋯ menu and Close. You can [colour it](/reference/sdk-layout#lia-setheadercolor) and show a [back button](/reference/sdk-layout#lia-backbutton) in it. The ⋯ menu always has: - **Settings**, when your page shows [`Lia.settingsButton`](/reference/sdk-layout#lia-settingsbutton). - **Notifications on or off**, when your app may send them (see [Notifications](/concepts/notifications)). - **Mute or unmute sounds**, which your page hears as [`soundChanged`](/concepts/look-and-feel#sound). - **Reload** and **Close**. ## While your page loads Lia shows its own loader over your app from the moment it opens until your page calls [`Lia.ready()`](/reference/sdk-lifecycle#lia-ready) (or finishes loading). Call it as soon as your first screen is drawn. If your page fails to load, Lia shows a message with Try again. ## Pause and resume Your page hears `pause` when Lia goes to the background or covers your app with one of its own screens (the pay sheet, an ad, a dialog, the scanner), and `resume` when it's back. Pause timers, music and game loops on `pause`. See [Lifecycle and events](/reference/sdk-lifecycle). ## One sheet at a time Lia shows one of its own screens over your app at a time. While the pay sheet, an ad, a dialog, the friend picker, the notifications ask, the scanner or Face ID is open, another such call fails with `busy`. ## Who may open your app - People need a Lia **username** to open any app. - While your app is in **test**, only you and your testers can open it. When it's **live**, everyone can. When it's **paused**, nobody can. See [Test and live](/concepts/test-and-live). - If the Lia team set a **minimum streak** for your app, people need a check-in streak that long to open it. Lia shows them how to get there. # Players and sessions Source: https://docs.lialabs.org/concepts/players-and-sessions ## Players People never sign in to your app: Lia does it for them. The first time someone opens your app, Lia makes them a **player** of it. [`Lia.init()`](/reference/sdk-lifecycle#lia-init) gives you: ```json { "player": { "id": "p_3f9c0a6d1b2e4f5a6b7c", "name": "amara" }, "session": "lias_eyJhcHAiOi…", "exp": 1761998400000, "mode": "live" } ``` - **`player.id`** is this person's id in your app. It stays the same every time they open it, on any phone. It's pseudonymous: it's only for your app, never their Lia account, and another app gets a different id for the same person. Use it as the key for everything you keep about a player. - **`player.name`** is their Lia username, without the @. It's public and can change; Lia keeps it current each time they open your app. Show it, but don't key anything on it. Lia never gives your app a person's email, phone number, balance or anything else about their account. ## Sessions Your page can't prove who it is to your server: anyone can send your server a request that claims to be any player. So Lia gives your page a **session token**, signed by Lia, that your server checks with Lia. - `Lia.init()` answers with a `session` and when it ends, `exp` (milliseconds since 1970). - A session lasts **15 minutes**. Get a fresh one with [`Lia.session()`](/reference/sdk-lifecycle#lia-session) whenever you need it; it answers `{ session, exp }`. - A session names your app, the player and the mode (test or live). A token from another app, from the other mode or past its end is not valid. ```js // In your page: send a fresh session with anything your server must trust. const { session } = await Lia.session(); await fetch("/api/claim", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ session, chargeId }), }); ``` Your server sends the token to [`POST /session/verify`](/reference/server-api#post-session-verify) with your secret key and gets back the player id: ```json { "valid": true, "playerId": "p_3f9c0a6d1b2e4f5a6b7c", "mode": "live", "exp": 1761998400000 } ``` > **Important** Never take a player id from the page on its word. Take it from `/session/verify`. [Verify on your server](/guides/verify-on-your-server) shows the whole pattern, including keeping your own longer session after the first check. ## Modes Every session is in one **mode**, `test` or `live`, and so is every key. A test key only verifies test sessions and a live key only live ones. See [Test and live](/concepts/test-and-live). ## Lia's own records about a player Some things Lia keeps per player in your app, so you don't need a server for them: - Their **best score** and place on your [leaderboard](/guides/scores-and-saves). - Their [**cloud storage**](/reference/sdk-scores-and-storage#lia-cloudstorage): up to 1024 keys. - Whether they **allowed your notifications**. - Their **charges**, **rewards** and **watched videos**, which your server can look up. # Test and live Source: https://docs.lialabs.org/concepts/test-and-live ## Status | Status | Who can open it | Your keys | | --- | --- | --- | | `test` | You (the app's developer) and its testers, in test mode | Test key works. Live key is refused. | | `live` | Everyone, in live mode. Your testers too, in live mode. | Both keys work. | | `paused` | Nobody. It's hidden from the Apps Center. | Both keys are refused with `bad_key`. | New apps start in test. The Lia team adds your testers (by Lia username) and switches the app to live when you're ready. A live app can't be opened in test mode; to keep testing changes after launch, ask the team for a second app in test that points at your staging address. ## Test mode While you test, everything works as it will live, with pretend $LIA: - **Lia's header says Test** next to your developer name, so nobody mistakes it for the real thing. - **Payments are pretend.** The pay sheet shows the real price, but nobody's balance moves and nobody needs enough $LIA. The charge is real in every other way: your server sees it, delivers it and can refund it, and webhooks are sent. - **Rewards are pretend.** Your pool isn't used and the daily limit per player doesn't apply. The reward is recorded and its webhook is sent, but no $LIA reaches the player. - **Notifications reach testers only**, can be sent every 30 seconds (instead of every hour) and at least 20 a day, and don't count toward the person's daily total from all apps. - **Ads are real ads**, played and counted as in live. ## Keys and modes Your **test key** (`lia_test_…`) only sees test mode: test sessions, test charges, test players and test ads. Your **live key** (`lia_live_…`) only sees live ones. A charge made in test is `not_found` to the live key, and the other way round. So your server can tell which mode it's in from the key it uses. A good pattern: ```js // One server for both: pick the key by where it runs. const LIA_KEY = process.env.NODE_ENV === "production" ? process.env.LIA_LIVE_KEY : process.env.LIA_TEST_KEY; ``` Every response that involves a player or a charge also says its `mode`. > **Test mode** Your testers are players in both modes with the same player id. Their scores and cloud storage are shared between test and live, so clear test data from your own records before you launch if it matters. ## What doesn't change between modes - The player id, name and everything in [`Lia.init()`](/reference/sdk-lifecycle#lia-init) except `mode`. - Leaderboards and cloud storage (one of each per app). - Your reward pool's balance (only live rewards use it). # Payments and the pay sheet Source: https://docs.lialabs.org/concepts/payments ## How a payment works
  1. Your page asksLia.pay("extra-lives") with the id of one of your products.
  2. Lia's pay sheet opens over your appIt shows the item and its price from Lia's records (never your page's), your app and developer name, and the person's balance after.
  3. The person confirmsThe $LIA leaves their balance. Lia Labs holds your share for you, and the charge is recorded for your app as paid.
  4. Your page hears itpay() answers { chargeId, productId }. A charge.paid webhook goes to your server too.
  5. Your server deliversIt checks the charge with Lia, gives the person what they bought, and marks the charge delivered. Or, if it can't, refunds it.
Your page never handles money, cards or wallets, and can't change a price: the sheet only ever shows what's in Lia's records. ## Products What your app sells is set up by the Lia team (until the developer portal arrives). Each product has: | Field | Rules | | --- | --- | | `id` | 1 to 64 characters: letters, digits, `_`, `.` and `-`, starting with a letter or digit. This is what you pass to `Lia.pay()`. | | `name` | Up to 60 characters, shown on the pay sheet and in the person's activity. | | `price` | In $LIA, more than 0 and up to 1,000,000, to 6 decimal places. | | `active` | Removing a product only hides it: its past charges stay. | Your server can list them with [`GET /products`](/reference/server-api#get-products). ## Charges A charge is one payment. It has an id like `ch_9b1f…` and a status: | Status | Meaning | | --- | --- | | `paid` | The person paid. Your app hasn't delivered it yet. | | `delivered` | Your server said it delivered it. Delivering again changes nothing. | | `refunded` | The $LIA went back to the person. A refunded charge can't be delivered. | Your server works with charges through [`GET /charges/:id`](/reference/server-api#get-charges-id), [`POST /charges/:id/deliver`](/reference/server-api#post-charges-id-deliver) and [`POST /charges/:id/refund`](/reference/server-api#post-charges-id-refund). ## Your share When a live charge is paid, the full price leaves the person's balance. Your share (70% unless agreed otherwise with the Lia team) is held for you by Lia Labs, and the rest stays with Lia. A refund gives the person the full price back, out of both. Lia Labs pays out your share monthly, into your own Lia balance. From $LIA mainnet in Q1 2028, you can withdraw it to your own wallet. See [Getting paid](/access#getting-paid). ## Never lose a payment A page can reload, crash or lose its connection between the pay sheet and your server. Lia keeps every paid charge until your server delivers it, and gives your page three ways to find them: - **`pay()` answers** with `{ chargeId, productId }` in the normal case. - **`Lia.onCharge(fn)`** hears a charge that was confirmed while no `pay()` was waiting, for example after the page reloaded while the sheet was open. - **`Lia.charges()`** lists this player's paid, undelivered charges (oldest first, up to 50). Call it when your app opens. And your server can rely on the **`charge.paid` webhook** instead of the page. [Sell an item](/guides/sell-an-item) puts all of these together. ## When a payment doesn't happen `Lia.pay()` fails with an error whose `code` says why: | Code | When | | --- | --- | | `cancelled` | The person closed the sheet without paying. The most common one: treat it as a normal "no". | | `no_product` | There's no active product with that id for your app. | | `busy` | Another of Lia's screens is already open over your app. | | `bad_productId` | The id isn't text up to 64 characters. | If the person doesn't have enough $LIA, or the payment fails on the way, the pay sheet tells them itself and stays open so they can try again. Your page only hears `cancelled` when they close it. ## Test mode In test mode the pay sheet says so, nobody's balance moves, and nobody needs enough $LIA. Everything else (your server's calls, webhooks, delivering and refunding) works as in live. See [Test and live](/concepts/test-and-live). # Reward pools Source: https://docs.lialabs.org/concepts/reward-pools ## What a pool is A reward pool is $LIA the Lia team sets aside for your app from Lia's treasury. Your server pays players from it: for winning a tournament, finishing a quest, watching a video, anything your app decides. The $LIA goes straight to the player's balance in Lia, and shows in their activity under your app's name with the reason you give. The Lia team decides whether your app has a pool, funds it, and can top it up or take some back. Ask them about one when you [talk to us](/access). ## Paying a reward Your server calls [`POST /rewards`](/reference/server-api#post-rewards) with your secret key: ```json { "playerId": "p_3f9c0a6d1b2e4f5a6b7c", "amount": 25, "key": "tournament-2026-11-01:p_3f9c0a6d1b2e4f5a6b7c", "reason": "Weekly tournament, 1st place" } ``` Rewards only ever come from your server. There's no SDK call for them, because a page can't be trusted to say what someone earned. ## Once per key Every reward has a **key** that you choose, up to 128 characters of letters, digits, `_`, `.`, `:` and `-`. A key pays once, ever: sending the same key again pays nothing and answers `{ "status": "duplicate" }` with the amount it paid the first time. So: - Make the key say what the reward is for, so a retry or a double click can't pay twice: `daily:2026-11-01:p_3f9c…`, `quest-7:p_3f9c…`, `ad:ad_8e1c…`. - It's safe to retry a reward after a timeout with the same key. ## Limits | Limit | What happens | | --- | --- | | One reward is more than 0 and at most 10,000 LIA, to 6 decimal places. | `400 bad_amount` | | The pool must hold the amount. | `409 pool_empty`, with what's left | | A player can get at most the **daily limit per player** from your pool in a day (UTC). The Lia team sets it; 0 means no limit. | `429 daily_limit`, with the limit | [`GET /pool`](/reference/server-api#get-pool) tells your server what's left and the daily limit. Watch the balance and ask the team to top it up before it runs out. ## Test mode With your test key, rewards are pretend: they're recorded and their webhook is sent, but your pool isn't used, the daily limit doesn't apply, and no $LIA reaches the player. Keys are still once only. ## Webhooks Every reward paid sends a `reward.paid` [webhook](/reference/webhooks) with its key, player, amount, reason and mode. A duplicate sends nothing. [Reward players](/guides/reward-players) puts it together with a daily bonus as the example. # Notifications Source: https://docs.lialabs.org/concepts/notifications ## How they work
  1. The person allows themYour page calls Lia.requestNotifications() at a good moment, and Lia asks in its own sheet. People can also turn your app's notifications on or off from its ⋯ menu.
  2. Your server sends onePOST /notifications with the player's id, the text and an optional startParam.
  3. It reaches their phoneUnder your app's name, as a push and in Lia's bell. Tapping it opens your app with your startParam.
## Asking Ask when a notification would clearly help the person: their turn in a game, a delivery, a friend joining. Don't ask the moment your app opens. ```js if (!Lia.notificationsAllowed) { const { allowed } = await Lia.requestNotifications(); if (allowed) tellYourServer(); // so it knows it may notify this player } ``` - If they already allowed them, `requestNotifications()` answers `{ allowed: true }` without asking. - If your app may not send notifications at all (the Lia team turned them off), it fails with `notifications_off`. - `Lia.notificationsAllowed` says where things stand, and the `notificationsChanged` event fires when the person changes it, from your ask or the ⋯ menu. Lia doesn't tell your server when someone allows or stops notifications. Tell it yourself, or just send and handle `not_allowed`. ## Sending ```json { "playerId": "p_3f9c0a6d1b2e4f5a6b7c", "text": "It's your move against @amara.", "startParam": "match-8k2d" } ``` See [`POST /notifications`](/reference/server-api#post-notifications) for every field and answer. ## Limits Notifications are a privilege people give your app, so Lia keeps them few: | Limit | Live | Test | | --- | --- | --- | | Who | Players who allowed your app's notifications | Your testers who allowed them | | A day, from your app to one person | Your app's daily number, 3 unless the Lia team changed it | That number, and at least 20 | | Apart, from your app to one person | 1 hour | 30 seconds | | A day, from all apps together to one person | 6 | Not counted | | Text | 1 to 180 characters, spaces and new lines become single spaces | Same | | Not allowed in the text | Links, phone numbers and words used in scams (as in Lia's chats) | Same | When a limit is reached, `POST /notifications` answers `429 rate_limited`. For your app's own limits it also says when you may try again, as `retryAt` (milliseconds since 1970). "A day" is the last 24 hours for your app's limits, and the person's calendar day for the total from all apps. ## Opening your app from one A notification opens your app with its `startParam` (1 to 64 of letters, digits, `_` and `-`), which your page reads from `Lia.startParam`. If your app is already open when the person taps it, your page hears a `startParam` event instead. ```js function route(param) { if (param && param.startsWith("match-")) openMatch(param); } Lia.init().then(function () { route(Lia.startParam); Lia.ready(); }); Lia.on("startParam", function (data) { route(data.startParam); }); ``` ## Pushes and the bell Every notification goes to Lia's bell. It's also pushed to the person's phone when they have Lia's pushes on; `POST /notifications` answers `pushed: false` when it only reached the bell. # Invites and links Source: https://docs.lialabs.org/concepts/invites ## Two ways in | | `Lia.inviteFriend()` | `Lia.inviteLink()` | | --- | --- | --- | | What it is | Lia's friend picker | A link to your app | | Reaches | The person's Lia friends, as a card in their Lia chat | Anyone you share it with, anywhere | | Opens | Your app in Lia, with your `startParam` | Your app in Lia, with your `startParam`, or the App Store without Lia | | Answers | `{ invited }`: whether anyone was invited | The link, straight away | ## The startParam A startParam is how an invite says where to go: your match, room, table or anything else. It's 1 to 64 characters of letters, digits, `_` and `-`. When someone opens your app from an invite, a link or a [notification](/concepts/notifications), `Lia.startParam` holds it. If your app is already open, your page hears a `startParam` event instead. ```js // Make a room on your server, then invite into it. const room = await createRoom(); // "room-k3x9" await Lia.inviteFriend({ startParam: room }); // Whoever opens the invite lands in the same room. Lia.init().then(function () { if (Lia.startParam) joinRoom(Lia.startParam); Lia.ready(); }); Lia.on("startParam", function (data) { if (data.startParam) joinRoom(data.startParam); }); ``` Treat a startParam as a hint from anyone: check on your server that the room exists and the player may join it. ## The friend picker `Lia.inviteFriend()` opens Lia's own sheet over your app, with the person's friends and a way to share the link elsewhere. Each friend they pick gets a card in their Lia chat that opens your app. Your app only hears whether anyone was invited (or the link was shared), never who. Lia keeps it friendly: - **Friends only.** People can only invite their Lia friends, and not anyone either of them has blocked. - **Who can open it.** While your app is in test, only your testers can be invited. - **20 a day.** One person can invite at most 20 friends into your app a day. ## Invite links `Lia.inviteLink(startParam)` gives you a link like: ```text https://api.lialabs.org/play/your-app?start=room-k3x9 ``` Opened on a phone with Lia, it opens your app with the startParam. Without Lia, it shows a page in the phone's language with a way to get Lia. Call it after `Lia.init()`; without a startParam it's a link to your app itself. ```js const link = Lia.inviteLink(room); await Lia.share("Play with me: " + link); // or await Lia.copyText(link); ``` [Multiplayer with invites](/guides/multiplayer-with-invites) builds a two player room from start to finish. # Public tables Source: https://docs.lialabs.org/concepts/lobby Invites bring friends in. Public tables bring in anyone: your server lists a table that's waiting for players, people find it from your app or from Lia's own lobby, and your server seats them through Lia. Lia's own Lia XO works this way. ## How it works
  1. Your server lists a tablePOST /lobby/tables with its id, its host, the seats and a title. Lia only keeps the listing; the game, its seats and its rules stay on your server.
  2. People find itIn your app with Lia.lobby.tables(), or in Lia's own lobby in the Apps Center, which opens your app with the startParam lobby-<table id>.
  3. Your server seats themPOST /lobby/tables/:id/sit with the player's id. Lia refuses people the host blocked or who blocked the host, and full tables.
  4. The table leaves the listWhen the last seat is taken, when your server takes it out, or when its listing ends by itself.
## Safe with strangers - **No chat.** Strangers at a table see each other's username and play together; they can't message each other through Lia. - **Blocks hold.** Nobody sees the tables of someone they blocked or who blocked them, and `/sit` refuses them. - **Reports.** `Lia.lobby.report(playerId, reason)` reports a player to the Lia team (for their name, unfair play, abuse or anything else). Reporting also blocks them for the person, so neither sees the other's tables again. - **Titles are filtered** like everything else people read on Lia. ## Limits | Limit | Why | | --- | --- | | A host has at most 3 open tables in your app at a time (`429 too_many_tables`) | Nobody fills the lobby with their own tables. | | A listing lasts 60 seconds to an hour (30 minutes by default), then ends by itself | The list only shows games that are really waiting. | | A table has 2 to 10 seats | | | A title is up to 40 characters | It has to fit Lia's lobby. | | `stake` and `pot` are for show, up to 10,000 LIA | Lia doesn't hold them: charge stakes with your own product on [Lia's pay sheet](/concepts/payments). | | One person can make 20 reports a day | Reports stay meaningful. | | `Lia.lobby.tables()` returns up to 50 tables a page | Use `next` for more. | ## In test With your test key, tables are listed in test mode and only your testers see them. Live tables only appear while your app is live, and only live apps' tables appear in Lia's own lobby. [Multiplayer with invites](/guides/multiplayer-with-invites#public-tables) adds a public table to the two player room, and the [SDK](/reference/sdk-notifications-and-invites#lia-lobby) and [server](/reference/server-api#lobby) references have every field. # Ads Source: https://docs.lialabs.org/concepts/ads ## Three formats | Format | What it is | When to use it | | --- | --- | --- | | `rewarded` | A video the person chooses to watch, for something in return. | "Watch a video for a second chance." | | `rewardedInterstitial` | Lia offers it first, with Watch and Skip, then plays it for a reward. | A reward at a natural break, without your own button. | | `interstitial` | A full screen ad between moments, with nothing in return. | Between rounds or levels. Lia paces these. | Your page asks with [`Lia.showAd({ format })`](/reference/sdk-payments-and-ads#lia-showad), or the shortcuts `Lia.showRewardedAd()`, `Lia.showRewardedInterstitialAd()` and `Lia.showInterstitialAd()`. Lia loads and plays the ad over your app, with its own loader while it loads, and pauses your app until it's closed. ## What you get back Every ad call answers once the ad is closed: ```json { "watched": true, "shown": true, "adId": "ad_8e1c4b0f2a9d47c6b3e15a07" } ``` | Field | Meaning | | --- | --- | | `watched` | A rewarded video was watched to the end. Give the reward. Always `false` for an interstitial. | | `shown` | An ad went on screen. | | `adId` | The ad's id, for your server to check. Present when `watched` is true, and when an interstitial was shown. | | `reason` | Why there's no reward, when `watched` is false. | The reasons you'll see: | Reason | Meaning | | --- | --- | | `cancelled` | They closed the video before the end. | | `skipped` | They chose Skip when Lia offered a rewarded interstitial. | | `no_ad` | No ad was ready to show. | | `too_soon` | An interstitial, and it isn't its turn yet. | | `failed` | Lia couldn't record the view. | | Anything else | A connection or server problem, such as `network`. | If a rewarded video stops playing partway because of a problem on the phone, Lia counts it as watched so nobody loses a reward over a broken ad. ## Pacing Interstitials are paced by Lia, together with Lia's own ads: one has to wait a few minutes after the last full screen ad, and only so many show a day. When it isn't their turn, `showInterstitialAd()` answers straight away with `reason: "too_soon"` and nothing is shown. Just carry on. Ask at every natural break and Lia decides. Rewarded videos aren't paced, because people choose them. ## Checking on your server For any reward that matters, don't take the page's `watched: true` on its word. Send the `adId` to your server, and have it ask Lia with [`POST /ads/verify`](/reference/server-api#post-ads-verify): ```json { "adId": "ad_8e1c4b0f2a9d47c6b3e15a07", "playerId": "p_3f9c0a6d1b2e4f5a6b7c", "watched": true, "watchedAt": 1761998400000 } ``` Check that `watched` is true and the `playerId` is the player asking. Lia doesn't mark an ad as used, so pay each `adId` once yourself: using `ad:` as your [reward key](/concepts/reward-pools#once-per-key) does it for you. [Rewarded ads that pay](/guides/rewarded-ads) shows the whole flow. ## Following along Pass `onState` to hear each stage, or listen to every ad with `Lia.onAd(fn)`: ```js Lia.showRewardedAd({ onState: function (state) { // "loading", "showing", "closed" if (state === "loading") pauseMusic(); if (state === "closed") resumeMusic(); }, }); ``` ## What you earn You get 50% of what the ads in your app earn. Lia counts every ad your app shows and what it earns, and pays your part monthly into your own Lia balance, with your share of sales. See [Getting paid](/access#getting-paid). # Look and feel Source: https://docs.lialabs.org/concepts/look-and-feel ## Lia's theme Lia passes its colours to your page, light or dark, as the person has Lia set. After [`Lia.init()`](/reference/sdk-lifecycle#lia-init) they're CSS variables on your page's root, kept current when the person switches: ```css body { background: var(--lia-bg-color); color: var(--lia-text-color); } .card { background: var(--lia-secondary-bg-color); } .hint { color: var(--lia-hint-color); } button.primary { background: var(--lia-button-color); color: var(--lia-button-text-color); } ``` | Variable | From `Lia.theme` | What it is | | --- | --- | --- | | `--lia-bg-color` | `bg_color` | The page's background | | `--lia-text-color` | `text_color` | Text | | `--lia-hint-color` | `hint_color` | Quieter text | | `--lia-link-color` | `link_color` | Links (Lia blue) | | `--lia-button-color` | `button_color` | Buttons (Lia blue) | | `--lia-button-text-color` | `button_text_color` | Text on buttons | | `--lia-secondary-bg-color` | `secondary_bg_color` | Cards and wells | | `--lia-header-bg-color` | `header_bg_color` | Lia's header | | `--lia-bottom-bar-bg-color` | `bottom_bar_bg_color` | Behind the bottom buttons | | `--lia-accent-text-color` | `accent_text_color` | Accented text | | `--lia-section-bg-color` | `section_bg_color` | Sections | | `--lia-section-header-text-color` | `section_header_text_color` | Section headings | | `--lia-subtitle-text-color` | `subtitle_text_color` | Subtitles | | `--lia-destructive-text-color` | `destructive_text_color` | Destructive actions | | `--lia-section-separator-color` | `section_separator_color` | Hairlines | | `--lia-color-scheme` | `Lia.colorScheme` | `light` or `dark` | The names follow Telegram's theme parameters, so code written for Telegram mini apps maps across. Listen for the `theme` event to redraw anything you paint yourself (a canvas, say). You can also set the colour behind your page and of Lia's header with [`Lia.setBackgroundColor`](/reference/sdk-layout#lia-setbackgroundcolor) and [`Lia.setHeaderColor`](/reference/sdk-layout#lia-setheadercolor). ## The viewport `Lia.viewport` is `{ height, expanded }`: the height your page has in px, and whether a sheet is pulled up to full height. The `viewport` event fires when it changes (the sheet pulled up, bottom buttons shown), and `--lia-viewport-height` holds the height. ## Safe areas Phones have notches and home bars. Lia passes them as `Lia.safeArea` and CSS variables, in px: | Variable | What it is | | --- | --- | | `--lia-safe-area-inset-top`, `-bottom`, `-left`, `-right` | The phone's own safe area (`Lia.safeArea`) | | `--lia-content-safe-area-inset-top`, `-bottom`, `-left`, `-right` | Clear of Lia's floating Close and ⋯ in fullscreen too (`Lia.contentSafeArea`) | In a sheet or under Lia's header, you mostly need the bottom one. In fullscreen, keep content below the content safe area's top: ```css .app { padding-top: var(--lia-content-safe-area-inset-top, 0px); padding-bottom: var(--lia-safe-area-inset-bottom, 0px); } ``` ## Language `Lia.language` is the language the person uses Lia in, one of Lia's 13: | Code | Language | Code | Language | | --- | --- | --- | --- | | `en` | English | `id` | Bahasa Indonesia | | `es` | Español | `fil` | Filipino | | `pt` | Português | `sw` | Kiswahili | | `fr` | Français | `hi` | हिन्दी | | `tr` | Türkçe | `ar` | العربية (right to left) | | `ru` | Русский | `zh` | 中文 | | `vi` | Tiếng Việt | | | Show your app's own words in it. The SDK sets your page's `lang` for you; `Lia.rtl` says whether the language reads right to left, so set the direction yourself: ```js Lia.init().then(function () { document.documentElement.dir = Lia.rtl ? "rtl" : "ltr"; render(WORDS[Lia.language] || WORDS.en); Lia.ready(); }); Lia.on("languageChanged", function (data) { document.documentElement.dir = data.rtl ? "rtl" : "ltr"; render(WORDS[data.language] || WORDS.en); }); ``` ## Sound People can mute your app from Lia's ⋯ menu, and Lia remembers it for your app. `Lia.soundOn` says whether they want your sounds, and `soundChanged` says when it changes. Play sounds only while it's on. ```js let soundOn = Lia.soundOn; Lia.on("soundChanged", function (data) { soundOn = data.on; if (!soundOn) music.pause(); }); function play(sound) { if (soundOn) sound.play(); } ``` Your page may play sound without waiting for a first tap. To mix with music already playing on the phone instead of stopping it, set `navigator.audioSession.type = "ambient"` where the phone supports it (Lia's own games do). ## Platform `Lia.platform` is `ios` or `android` after `init()`. Lia's frame is the phone's own web view: Safari's on iPhone, Chrome's on Android. # Sell an item Source: https://docs.lialabs.org/guides/sell-an-item You'll sell "3 extra lives" for 5 LIA. The person pays on Lia's sheet, your server checks the charge with Lia, adds the lives, and marks the charge delivered. ## 1. Add the product Ask the Lia team to add a product to your app: id `extra-lives`, name "3 extra lives", price 5. Your server can check it's there: ```sh curl https://api.lialabs.org/api/apps/v1/products -H "Authorization: Bearer $LIA_KEY" ``` ## 2. In your page: pay, then tell your server ```js title="shop.js" async function buyExtraLives() { let charge; try { charge = await Lia.pay("extra-lives"); } catch (error) { if (error.code === "cancelled") return; // they changed their mind Lia.toast("Couldn't open the payment", { tone: "error" }); return; } await deliver(charge.chargeId); } // Your server checks the charge and gives the lives. Safe to call twice for one charge. async function deliver(chargeId) { const { session } = await Lia.session(); const res = await fetch("/api/deliver", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ session, chargeId }), }); if (!res.ok) return Lia.toast("We'll add it as soon as we can", { tone: "error" }); const { lives } = await res.json(); showLives(lives); Lia.haptic("success"); } ``` ## 3. On your server: check, deliver, mark it ```js title="server.js" import { lia } from "./lia.js"; // the helper from the Server API page app.post("/api/deliver", async (req, res) => { // Who is asking: from Lia, not from the page. const { valid, playerId } = await lia("POST", "/session/verify", { session: req.body.session }); if (!valid) return res.status(401).json({ error: "session" }); // Is this charge real, paid, and theirs? let charge; try { ({ charge } = await lia("GET", `/charges/${encodeURIComponent(req.body.chargeId)}`)); } catch (error) { return res.status(404).json({ error: "no_charge" }); } if (charge.playerId !== playerId) return res.status(403).json({ error: "not_yours" }); if (charge.status === "refunded") return res.status(409).json({ error: "refunded" }); // Give it once, keyed on the charge id (a second call finds it already given). const lives = await db.grantOnce(charge.id, playerId, () => db.addLives(playerId, 3)); // Then tell Lia it's delivered (safe to repeat). await lia("POST", `/charges/${charge.id}/deliver`); res.json({ lives }); }); ``` The important parts: - **The player comes from `/session/verify`**, never from the page. - **The charge comes from `GET /charges/:id`**: its player, product and status are Lia's. - **Grant once per charge id**, in your own database, so a retry, a webhook and the page can't give it twice. Do it in one transaction with recording the charge id. - **Deliver after granting.** If your server dies between the two, the charge stays `paid` and you'll see it again (below), and your once-per-charge check stops a double grant. ## 4. Never lose one The page can reload or lose its connection between paying and your server. Catch up when your app opens, and when a charge arrives without a `pay()` waiting: ```js Lia.init().then(async function () { Lia.ready(); for (const charge of await Lia.charges()) await deliver(charge.chargeId); }); Lia.onCharge(function (charge) { deliver(charge.chargeId); }); ``` ## 5. Or deliver from a webhook If your server gives items without the page (adding coins to an account, say), use the `charge.paid` [webhook](/guides/webhooks) too. Check the charge with `GET /charges/:id`, grant once per charge id, then deliver. With both the page and the webhook, whichever arrives first delivers and the other finds it done. ## When you can't deliver If something you sold can't be given (it sold out, the account is gone), give the $LIA back: ```js await lia("POST", `/charges/${charge.id}/refund`); ``` The person gets the full price back and a refunded charge can't be delivered. Tell them in your page. ## Test it With your test key and your app in test, buy it as a tester: the sheet says Test, no $LIA moves, and everything else happens for real. See [Testing](/guides/testing). # Verify on your server Source: https://docs.lialabs.org/guides/verify-on-your-server Anyone can send your server a request that says "I'm player p_3f9c…". So the page sends a **session token** that Lia signed, and your server asks Lia whose it is. ## In your page Send the token with your first request, or with anything that matters. Tokens last 15 minutes; `Lia.session()` always gives a fresh one. ```js title="api.js" export async function api(path, body) { const { session } = await Lia.session(); const res = await fetch(path, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ...body, session }), }); if (!res.ok) throw new Error((await res.json()).error); return res.json(); } ``` ## On your server ```js title="server.js (Node.js)" const LIA = "https://api.lialabs.org/api/apps/v1"; async function playerFrom(session) { if (typeof session !== "string") return null; const res = await fetch(`${LIA}/session/verify`, { method: "POST", headers: { Authorization: `Bearer ${process.env.LIA_KEY}`, "Content-Type": "application/json" }, body: JSON.stringify({ session }), }); if (!res.ok) throw new Error(`Lia answered ${res.status}`); // a 401 means your key is wrong const result = await res.json(); return result.valid ? result.playerId : null; } app.post("/api/save", async (req, res) => { const playerId = await playerFrom(req.body.session); if (!playerId) return res.status(401).json({ error: "session" }); await db.save(playerId, req.body.progress); res.json({ ok: true }); }); ``` The same in a Cloudflare Worker: ```js title="worker.js" export default { async fetch(request, env) { const body = await request.json(); const check = await fetch("https://api.lialabs.org/api/apps/v1/session/verify", { method: "POST", headers: { Authorization: `Bearer ${env.LIA_KEY}`, "Content-Type": "application/json" }, body: JSON.stringify({ session: body.session }), }).then((r) => r.json()); if (!check.valid) return Response.json({ error: "session" }, { status: 401 }); return Response.json({ hello: check.playerId }); }, }; ``` ## Keep your own session Verifying every request works, but it's a round trip to Lia each time. For an app that talks to its server a lot, verify once and then use your own:
  1. The page signs inIt sends the token from Lia.init() to /api/login.
  2. Your server verifies it with LiaIt gets the player id, and sets your own cookie or token for that player.
  3. Everything after uses yoursUntil it ends; then the page sends a fresh Lia session to log in again.
Cookies set by your page's own site work inside Lia's frame as on the web (cookies for another site may be blocked by the phone, so keep your API on the same site or send a token instead). For a WebSocket, verify the token in the first message (or the connection's query string) before joining the player to anything. ## Checks to make - **`valid` is true.** A token that's for another app, from the other mode or past its end isn't valid. - **The mode is the one you expect.** With your live key you'll only ever see live sessions, but if your server keeps both keys, check `mode`. - **Use `playerId` from Lia**, never one the page sends. - **Keep your key on your server.** Never send it to the page, even for a moment. # Handle webhooks Source: https://docs.lialabs.org/guides/webhooks Lia sends `charge.paid`, `charge.refunded` and `reward.paid` to your webhook address, signed with your webhook secret. See [Webhooks](/reference/webhooks) for every field. ## Node.js with Express Read the body raw: the signature is over the exact bytes Lia sent, so check it before any JSON parsing. ```js title="webhook.js" import express from "express"; import { createHmac, timingSafeEqual } from "node:crypto"; import { lia } from "./lia.js"; const app = express(); function verified(rawBody, header, secret) { const parts = Object.fromEntries((header ?? "").split(",").map((part) => part.split("="))); const t = Number(parts.t); if (!t || !parts.v1 || Math.abs(Date.now() / 1000 - t) > 300) return null; const expected = Buffer.from(createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex")); const given = Buffer.from(parts.v1); if (expected.length !== given.length || !timingSafeEqual(expected, given)) return null; return JSON.parse(rawBody); } app.post("/lia/webhook", express.raw({ type: "application/json" }), async (req, res) => { const event = verified(req.body.toString("utf8"), req.get("Lia-Signature"), process.env.LIA_WEBHOOK_SECRET); if (!event) return res.status(400).end(); // Answer first; do the work after. res.status(200).end(); if (event.type === "charge.paid") { // A nudge: check with Lia, then grant once per charge. const { charge } = await lia("GET", `/charges/${event.data.id}`); if (charge.status !== "paid") return; await db.grantOnce(charge.id, charge.playerId, () => grant(charge.playerId, charge.productId)); await lia("POST", `/charges/${charge.id}/deliver`); } if (event.type === "charge.refunded") { await db.takeBack(event.data.id); // if you'd already given it } if (event.type === "reward.paid") { await db.noteReward(event.data.key, event.data.playerId, event.data.amount); } }); ``` ## Cloudflare Workers ```js title="worker.js" async function verified(request, secret) { const rawBody = await request.text(); const parts = Object.fromEntries((request.headers.get("Lia-Signature") ?? "").split(",").map((part) => part.split("="))); const t = Number(parts.t); if (!t || !parts.v1 || Math.abs(Date.now() / 1000 - t) > 300) return null; const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["verify"]); const signature = new Uint8Array(parts.v1.match(/../g).map((byte) => parseInt(byte, 16))); const ok = await crypto.subtle.verify("HMAC", key, signature, new TextEncoder().encode(`${t}.${rawBody}`)); return ok ? JSON.parse(rawBody) : null; } export default { async fetch(request, env, ctx) { const url = new URL(request.url); if (request.method === "POST" && url.pathname === "/lia/webhook") { const event = await verified(request, env.LIA_WEBHOOK_SECRET); if (!event) return new Response(null, { status: 400 }); ctx.waitUntil(handle(event, env)); // the work, after answering return new Response(null, { status: 200 }); } return new Response("Not found", { status: 404 }); }, }; ``` ## Rules of thumb - **Check the signature, then check with the API.** A webhook says something happened; `GET /charges/:id` says what's true now. - **Act once.** Webhooks can arrive twice, and your page may have delivered already. Key what you do on the charge id or the reward key. - **Answer `2xx` quickly.** Lia tries a webhook twice, straight away, and then gives up. Do slow work after answering. - **Don't rely on webhooks alone.** Catch up with `Lia.charges()` when a player opens your app, so a missed webhook never loses a sale. - **Mind the mode.** Test events arrive at the same address: check `data.mode`. - **Ignore types you don't know**, and still answer `200`. ## Testing your handler Make a test purchase as a tester (see [Testing](/guides/testing)): it sends a real, signed `charge.paid` with `mode: "test"`. To try it on your computer, put your local server behind an https tunnel and ask the Lia team to point your webhook address there. # Multiplayer with invites Source: https://docs.lialabs.org/guides/multiplayer-with-invites The idea: a room has an id, and the id goes in the invite's **startParam**. Whoever opens the invite opens your app with that startParam, and your page joins them to the room. Lia's own Lia XO works this way. ## 1. Create a room on your server ```js title="server.js" app.post("/api/rooms", async (req, res) => { const playerId = await playerFrom(req.body.session); // see Verify on your server if (!playerId) return res.status(401).end(); const room = "room-" + crypto.randomUUID().replace(/-/g, "").slice(0, 10); await db.createRoom(room, { host: playerId, players: [playerId] }); res.json({ room }); }); app.post("/api/rooms/join", async (req, res) => { const playerId = await playerFrom(req.body.session); if (!playerId) return res.status(401).end(); const room = await db.room(req.body.room); if (!room) return res.status(404).json({ error: "no_room" }); if (room.players.length >= 2 && !room.players.includes(playerId)) return res.status(409).json({ error: "full" }); await db.addPlayer(req.body.room, playerId); res.json({ room: req.body.room }); }); ``` Room ids must fit a startParam: 1 to 64 of letters, digits, `_` and `-`. ## 2. Invite a friend ```js title="game.js" async function playWithAFriend() { const { room } = await api("/api/rooms", {}); showWaiting(room); const { invited } = await Lia.inviteFriend({ startParam: room }); if (!invited) Lia.toast("Share the link to invite someone"); } // Or a link to send anywhere: opens your app in Lia, or the App Store without it. async function shareRoom(room) { await Lia.share("Play with me: " + Lia.inviteLink(room)); } ``` Each friend picked gets a card in their Lia chat. Lia only lets people invite their friends, and only friends who can open your app (while it's in test, only your testers). ## 3. Land in the room Read the startParam when your app opens, and listen for one arriving while it's open (the person taps a second invite): ```js function route(startParam) { if (startParam && startParam.startsWith("room-")) joinRoom(startParam); } Lia.init().then(function () { route(Lia.startParam); Lia.ready(); }); Lia.on("startParam", function (data) { route(data.startParam); }); async function joinRoom(room) { try { await api("/api/rooms/join", { room }); openTable(room); } catch (error) { Lia.toast(error.message === "full" ? "That game is full" : "That game has ended"); } } ``` A startParam is only a hint: anyone can make a link with any startParam, so your server decides whether the room exists and the player may join. ## 4. Play live Connect both players to your server (a WebSocket is usual), and verify each player's session before joining them to the room. While a game is on: ```js Lia.enableClosingConfirmation(); // "Leave the game?" if they close Lia.disableVerticalSwipes(); // a swipe down won't close the sheet mid move Lia.onPause(function () { socket.send(JSON.stringify({ type: "away" })); }); Lia.onResume(function () { socket.send(JSON.stringify({ type: "back" })); }); ``` ## 5. Your turn, by notification When it's someone's move and they've left your app, a [notification](/guides/send-notifications) with the room as its startParam brings them straight back: ```json { "playerId": "p_3f9c0a6d1b2e4f5a6b7c", "text": "@kofi played. Your move.", "startParam": "room-a81c2e09f4" } ``` ## Public tables Invites are for friends. To let anyone on Lia join, also list the room in the [lobby](/concepts/lobby) while it waits: ```js title="server.js" // When the room is made: list it (as the host, with one seat taken). await lia("POST", "/lobby/tables", { tableId: room, hostPlayerId: playerId, seats: 2, taken: 1, title: "Quick game" }); // When someone joins from the lobby: seat them through Lia first (it refuses blocked players and full tables). app.post("/api/rooms/sit", async (req, res) => { const playerId = await playerFrom(req.body.session); if (!playerId) return res.status(401).end(); try { const { full } = await lia("POST", `/lobby/tables/${encodeURIComponent(req.body.room)}/sit`, { playerId }); await db.addPlayer(req.body.room, playerId); if (full) startGame(req.body.room); res.json({ room: req.body.room }); } catch (error) { res.status(409).json({ error: error.data?.error ?? "failed" }); // full, not_found, own_table } }); // When the game starts or the room closes: take it out (safe to repeat). await lia("DELETE", `/lobby/tables/${encodeURIComponent(room)}`); ``` In your page, show the open tables, and seat people who chose your table in Lia's own lobby (they arrive with the startParam `lobby-`): ```js const { tables } = await Lia.lobby.tables(); renderTables(tables, (table) => api("/api/rooms/sit", { room: table.id })); function route(startParam) { if (startParam?.startsWith("lobby-")) return api("/api/rooms/sit", { room: startParam.slice(6) }).then(openTable); if (startParam?.startsWith("room-")) return joinRoom(startParam); } ``` Strangers can't chat through Lia, blocks are respected, and players can [report](/reference/sdk-notifications-and-invites#lia-lobby) each other. ## Limits to know - One person can invite 20 friends into your app a day, so invites stay something people welcome in their chats. - A host can have 3 open public tables in your app at a time, and a listing lasts at most an hour. - `Lia.inviteFriend()` answers `{ invited }`, never who was invited: your server learns who joined when they join. # Reward players Source: https://docs.lialabs.org/guides/reward-players Your app needs a reward pool, funded by the Lia team (see [Reward pools](/concepts/reward-pools)). Rewards only ever come from your server, with your key. ## A daily bonus Each player can claim 2 LIA once a day, after finishing a level. ```js title="server.js" import { lia } from "./lia.js"; app.post("/api/daily-bonus", async (req, res) => { const playerId = await playerFrom(req.body.session); // see Verify on your server if (!playerId) return res.status(401).end(); if (!(await db.finishedALevelToday(playerId))) return res.status(403).json({ error: "play_first" }); const day = new Date().toISOString().slice(0, 10); // 2026-11-01 try { const result = await lia("POST", "/rewards", { playerId, amount: 2, key: `daily:${day}:${playerId}`, // one a day per player, however often this runs reason: "Daily bonus", }); res.json({ paid: result.status === "paid", amount: result.amount }); } catch (error) { if (error.data?.error === "pool_empty") return res.status(503).json({ error: "bonus_paused" }); if (error.data?.error === "daily_limit") return res.status(429).json({ error: "come_back_tomorrow" }); throw error; } }); ``` In the page: ```js const { paid, amount } = await api("/api/daily-bonus", {}); if (paid) { Lia.haptic("success"); Lia.toast("+" + amount + " LIA"); } ``` ## Choosing keys The key is what makes a reward safe. The same key never pays twice, so make it name the reason for the reward, not the request: | Reward | Key | | --- | --- | | A daily bonus | `daily:2026-11-01:p_3f9c…` | | A tournament place | `tournament-44:place-1:p_3f9c…` | | A finished quest | `quest-7:p_3f9c…` | | A watched video | `ad:ad_8e1c…` | A retry with the same key after a timeout answers `{ "status": "duplicate" }` and pays nothing more, so retrying is always safe. Never retry with a new key. ## Keep an eye on the pool ```js const { balance, dailyCapPerPlayer } = await lia("GET", "/pool"); if (balance < 100) alertTheTeam(`Reward pool is down to ${balance} LIA`); ``` When the pool can't cover a reward, `POST /rewards` answers `409 pool_empty`. Tell players kindly and ask the Lia team to top it up. When a player has had your app's daily limit, it answers `429 daily_limit` (the limit is per player per UTC day, set by the team). ## In test With your test key, rewards are pretend: recorded, with their webhook, but no $LIA moves, and the pool and daily limit aren't used. Try your keys and your error handling there first. ## Make rewards fair - **Decide on the server.** What someone earned should come from what your server saw (a finished game, a verified video), not from the page saying so. - **Mind your own limits** too, per player and per day, so one player can't drain the pool. - **Say why.** The `reason` shows in the player's activity under your app's name. # Rewarded ads that pay Source: https://docs.lialabs.org/guides/rewarded-ads The page plays the video and gets an `adId`. Your server checks the `adId` with Lia, then gives the reward, once. ## In your page ```js title="game.js" async function secondChance() { const result = await Lia.showRewardedAd({ onState: function (state) { if (state === "loading") pauseGame(); }, }); if (!result.watched) { if (result.reason === "no_ad") Lia.toast("No video right now. Try again soon."); return endRun(); } const { ok } = await api("/api/second-chance", { adId: result.adId }); ok ? continueRun() : endRun(); } ``` Offer the video with your own button ("Watch a video to keep going"). People choose rewarded videos, so Lia doesn't pace them. ## On your server ```js title="server.js" app.post("/api/second-chance", async (req, res) => { const playerId = await playerFrom(req.body.session); if (!playerId) return res.status(401).end(); let ad; try { ad = await lia("POST", "/ads/verify", { adId: req.body.adId }); } catch { return res.json({ ok: false }); } if (!ad.watched || ad.playerId !== playerId) return res.json({ ok: false }); // Once per video: a second request with the same adId gets nothing more. const first = await db.useOnce(`ad:${ad.adId}`); if (!first) return res.json({ ok: false }); await db.grantSecondChance(playerId); res.json({ ok: true }); }); ``` ## Paying $LIA for a video If the reward is $LIA from your [pool](/concepts/reward-pools), the reward key does the "once" for you: ```js await lia("POST", "/rewards", { playerId, amount: 0.5, key: `ad:${ad.adId}`, reason: "Watched a video" }); ``` ## Between rounds Interstitials need no server and give nothing. Ask at each natural break and let Lia pace them: ```js async function nextRound() { await Lia.showInterstitialAd(); // often answers "too_soon" at once, and that's fine startRound(); } ``` A rewarded interstitial sits between the two: Lia offers it with Watch and Skip, and a watched one has an `adId` to check like a rewarded video. ## Good to know - `/ads/verify` says `watched: true` for interstitials that were shown, too. Only reward `adId`s your page got from a rewarded format with `watched: true`. - If a video stops partway because of a problem on the phone, Lia counts it as watched, so nobody loses a reward over a broken ad. - Pause your game and its sound on `"loading"`; your page also hears `pause` and `resume` around the video. # Send notifications Source: https://docs.lialabs.org/guides/send-notifications ## 1. Ask when it helps ```js title="game.js" async function waitForOpponent(room) { if (!Lia.notificationsAllowed) { try { const { allowed } = await Lia.requestNotifications(); if (allowed) await api("/api/notify-me", { allowed: true }); } catch (error) { // "notifications_off": your app can't send them; carry on without. } } showWaiting(room); } // They can also turn them on or off from your app's ⋯ menu. Lia.on("notificationsChanged", function (data) { api("/api/notify-me", { allowed: data.allowed }); }); ``` Lia doesn't tell your server when someone allows notifications, so tell it yourself, as above. Or keep it simpler: send when you have something to say, and treat `not_allowed` as a "no". ## 2. Send from your server ```js title="server.js" async function notify(playerId, text, startParam) { try { return await lia("POST", "/notifications", { playerId, text, startParam }); } catch (error) { const code = error.data?.error; if (code === "not_allowed") return db.setNotify(playerId, false); // they said no or stopped if (code === "rate_limited") return db.retryLater(playerId, error.data.retryAt); // retryAt may be missing if (code === "bad_text") console.warn("Lia refused the text:", error.data.problem); throw error; } } await notify(opponent, "@amara played. Your move.", "room-a81c2e09f4"); ``` ## 3. Open the right screen ```js function route(startParam) { if (startParam?.startsWith("room-")) openRoom(startParam); } Lia.init().then(function () { route(Lia.startParam); Lia.ready(); }); Lia.on("startParam", function (data) { route(data.startParam); }); ``` ## Writing them - **Up to 180 characters**, one line. Lia shows it under your app's name, so don't start with it. - **No links, phone numbers or scammy words.** Lia refuses them with `bad_text` and a `problem` of `link`, `phone` or `scam`. The startParam is how you link into your app. - **Make each one count.** Your app can send one person 3 a day (unless the Lia team changed it), an hour apart, and all apps together 6 a day. Spend them on things the person wants to know. - **Write in their language.** Keep each player's `Lia.language` on your server when they open your app, and send in it. ## Testing Testers get test notifications from your test key every 30 seconds and at least 20 a day, and they don't count toward their daily total from all apps. See [Testing](/guides/testing). # Leaderboards and saves Source: https://docs.lialabs.org/guides/scores-and-saves ## A leaderboard ```js title="board.js" async function gameOver(score) { const { best } = await Lia.submitScore(score); if (score === best) Lia.haptic("success"); showBoard(await Lia.leaderboard()); } function showBoard({ top, me }) { list.innerHTML = top .map((row) => `
  • ${row.rank} @${escapeHtml(row.name)} ${row.score}
  • `) .join(""); if (me && me.rank > top.length) footer.textContent = `You: #${me.rank} with ${me.score}`; } ``` Escape names before putting them in HTML, as with any text from people. Each app has one leaderboard, with each player's best. Scores are whole numbers and higher is better; for a time or "fewest moves", submit something like `1000000 - milliseconds`. > **Important** `Lia.submitScore` is called by your page, so a player could send any score. Keep it for bragging rights. For prizes, have your server decide the winners from games it saw, and pay them from your [reward pool](/guides/reward-players). ## Saved progress Cloud storage keeps up to 1024 keys per player in your app on Lia's server, and follows them to any phone. ```js title="save.js" const SAVE = "save"; async function load() { const text = await Lia.cloudStorage.getItem(SAVE); return text ? JSON.parse(text) : { level: 1, coins: 0 }; } let saving = null; function save(state) { // Coalesce saves: at most one write every 2 seconds. clearTimeout(saving); saving = setTimeout(function () { Lia.cloudStorage.setItem(SAVE, JSON.stringify(state)).catch(function () { Lia.deviceStorage.setItem(SAVE, JSON.stringify(state)); // keep it on the phone meanwhile }); }, 2000); } Lia.onPause(function () { /* save now: they may not come back */ }); ``` Values are up to 4096 characters, so keep big things (a level editor's maps, say) on your own server or split them across keys and read them back with `getItems`. ## What goes where | Keep it in | When | | --- | --- | | `Lia.cloudStorage` | Progress, settings and anything the player expects on a new phone | | `Lia.deviceStorage` | Caches, drafts and anything you can lose (up to 5 MB) | | `Lia.secureStorage` | A token for your own server (up to 10 items) | | Your own server | Anything that's worth money or decides prizes, coins bought with $LIA, and what other players see | Cloud storage is the player's to change from your page, so treat it like anything from the page: fine for their own progress, not for balances. Test and live share it. # Make it feel like Lia Source: https://docs.lialabs.org/guides/feel-native ## A starter ```html title="index.html"
    ``` ## The checklist - **Call `Lia.ready()` as soon as your first screen is drawn**, not after everything has loaded. Lia's loader covers your app until then. - **Use Lia's colours.** Build on `--lia-bg-color`, `--lia-text-color`, `--lia-hint-color` and `--lia-secondary-bg-color`, and your app switches with Lia between light and dark. - **Mind the safe areas.** Pad the bottom with `--lia-safe-area-inset-bottom`; in fullscreen, keep content below `--lia-content-safe-area-inset-top`. - **Speak their language.** Use `Lia.language` for your words, set `dir` from `Lia.rtl`, and listen for `languageChanged`. Lia speaks 13 languages; English is a fine fallback. - **Use Lia's buttons for the main action.** `Lia.mainButton` sits where people expect it, above the home bar, in Lia blue. - **Use the back button for your own screens.** Show `Lia.backButton` one screen in, and handle `onClick`; hide it on your first screen. - **Use Lia's dialogs.** `Lia.confirm`, `Lia.showPopup` and `Lia.toast` look like the rest of Lia and read right to left when they should. - **Feel it.** `Lia.haptic("selection")` for choices, `"success"` and `"error"` for results. Not every frame. - **Respect sound.** Play only while `Lia.soundOn`, and stop on `soundChanged` and `pause`. - **Pause on `pause`.** Timers, game loops, music and sockets that matter. - **Ask before closing a game under way** with `Lia.enableClosingConfirmation()`, and turn it off when it's over. - **Stop swipes from closing a game that uses them** with `Lia.disableVerticalSwipes()`. - **No hover, no tiny targets.** It's a phone: 44 px tap targets at least, and no text selection on buttons. ## Colour your header If your app has a strong colour of its own, give Lia's header and background the same: ```js Lia.setHeaderColor("#0E1A3A"); Lia.setBackgroundColor("#0E1A3A"); ``` Lia turns the header's text and buttons light or dark to suit. # Testing your app Source: https://docs.lialabs.org/guides/testing ## Test mode New apps start in test. While they are: - Only you and your testers can open the app. Ask the Lia team to add testers by their Lia usernames. - Lia's header says **Test** next to your developer name. - Payments and rewards use pretend $LIA; nobody's balance moves. - Use your **test key** on your server. It only sees test sessions, charges and players. See [Test and live](/concepts/test-and-live) for everything that's different. ## Run it from your computer Lia only opens https pages from your app's address, so a page on `localhost` can't open in Lia directly. Put it behind an https tunnel, and ask the Lia team to point your test app at the tunnel's address: ```sh npx vite --port 5173 cloudflared tunnel --url http://localhost:5173 ``` A named tunnel (or any fixed https address) saves asking for a new address each time. ## In a normal browser Outside Lia, `Lia.available` is `false` and every call fails with `not_in_lia`. For working on layout in a desktop browser, a small stand in helps: ```js title="dev-lia.js" // Only when not inside Lia, and only in development. if (!window.Lia.available && location.hostname === "localhost") { const fake = { player: { id: "p_dev000000000000000000", name: "you" }, session: "dev", exp: Date.now() + 9e5, mode: "test", language: "en", rtl: false, platform: "web" }; window.Lia = Object.assign(Object.create(window.Lia), { init: async () => fake, ready() {}, session: async () => ({ session: "dev", exp: Date.now() + 9e5 }), toast: async (text) => console.log("toast:", text), haptic: async () => null, pay: async (productId) => ({ chargeId: "ch_dev", productId }), }); } ``` Your server won't verify `"dev"` sessions, so keep a development path there too, or test server flows inside Lia. ## The SDK demo Lia's own demo app tries every SDK call and shows what comes back: `https://api.lialabs.org/sdk/demo`. It opens inside Lia for people the team adds as its testers; ask if you'd like to try it. Its source is a good reference for each call. ## Finding problems inside Lia Lia's store builds don't allow attaching a web inspector to your page. Instead: - **Show errors on screen** while testing: a small log panel your testers can screenshot. - **Send errors to your server** with the player id, `Lia.version`, `Lia.platform` and the error's `code`. - **Check every rejection's `code`**, not just its message. ```js window.addEventListener("error", function (event) { report({ message: event.message, at: event.filename + ":" + event.lineno }); }); window.addEventListener("unhandledrejection", function (event) { report({ code: event.reason && event.reason.code, message: String(event.reason) }); }); ``` ## Before you go live - Payments: buy each product, close the sheet without paying, reload mid payment and check `Lia.charges()` finds it, and refund one. - Webhooks: a test purchase reaches your handler, its signature checks out, and a second delivery does nothing. - Rewards: a duplicate key pays nothing; `pool_empty` and `daily_limit` are handled. - Notifications: ask, send, tap one with your app closed and with it open. - Looks: light and dark, a right to left language (Arabic), a small phone, and fullscreen if you use it. - Your server switches to the **live key** in production, and nothing from your test data leaks into live. Then ask the Lia team to switch your app to live. # Packages Source: https://docs.lialabs.org/packages The [script tag](/reference/sdk) is the quickest way in. If your app has a build step, the npm packages give you the same SDK with TypeScript types, imports you can tree-shake, state that fits your framework, and a pretend Lia for building outside the app. ## Which package | Package | Use it for | | --- | --- | | [`@lia-labs/sdk`](/packages/sdk) | Any app with a bundler, with or without a framework. | | [`@lia-labs/sdk-react`](/packages/react) | React: hooks like `useInit`, `useMainButton`, `useBackButton`. | | [`@lia-labs/sdk-preact`](/packages/preact) | Preact: the same hooks. | | [`@lia-labs/sdk-vue`](/packages/vue) | Vue 3: composables returning refs. | | [`@lia-labs/sdk-svelte`](/packages/svelte) | Svelte 4 and 5: stores. | | [`@lia-labs/sdk-solid`](/packages/solid) | Solid: accessors and `create*` primitives. | | [`@lia-labs/sdk-angular`](/packages/angular) | Angular 17 and later: `inject*` functions and signals. | | [`@lia-labs/elements`](/packages/elements) | Any page, no framework: `` and friends. | | [`@lia-labs/bridge`](/packages/bridge) | The low level messages, for building your own layer. | Each framework package re-exports the whole SDK, so one import is enough: ```sh npm install @lia-labs/sdk @lia-labs/sdk-react ``` ```tsx import { pay, useInit, useMainButton } from '@lia-labs/sdk-react'; ``` ## How they fit
    1. @lia-labs/bridgeLia's frame messages, typed: request('pay', { productId }), on('theme', …). And mockLia(), a pretend Lia for building in a desktop browser.
    2. @lia-labs/sdkEvery feature as a named export (init, pay, mainButton, cloudStorage…), with the same checks and error codes as the script tag. Its state (theme, language, viewport…) is a set of small observable values.
    3. Framework packagesThin: they turn those values into hooks, refs, stores or signals, and tie Lia's buttons to your components, shown while mounted and hidden after.
    ## State you can watch The SDK's state is a set of `Readable` values: `get()` reads one now, and `subscribe(listener)` hears each change (with the new and old value) and returns a function that stops listening. They update from Lia's events after `init()`. | Value | Type | | --- | --- | | `player` | `{ id, name }` or `null` before `init()` | | `mode` | `"test"`, `"live"` or `null` | | `language`, `rtl` | The person's language in Lia, and whether it reads right to left | | `colorScheme`, `themeParams` | `"light"` or `"dark"`, and Lia's colours | | `viewport` | `{ height, expanded }` | | `safeArea`, `contentSafeArea` | `{ top, bottom, left, right }` in px | | `display`, `isFullscreen` | How the app opened, and whether it's edge to edge | | `startParam` | What it was opened with, kept current while open | | `soundOn`, `notificationsAllowed`, `notificationsAvailable` | The person's choices | | `initialized` | Whether `init()` has answered | Each framework package has a matching hook, composable, store or signal for each. ## The script tag still works `https://api.lialabs.org/sdk/lia-apps-v1.js` (the global `Lia`) is unchanged. Use it or the packages on a page, not both: both listen for Lia's answers on the same channel, and the second one to load can't. ## Versions The packages speak the protocol of SDK 1.6.0. A Lia app older than a feature answers `unknown_method`; see the [changelog](/changelog). # Local development Source: https://docs.lialabs.org/packages/local-development Outside the Lia app there's no frame bridge, so every call fails with `not_in_lia`. `mockLia()` puts a pretend Lia in its place: it answers every call the way Lia would, with a test player in test mode, so your whole app runs in a desktop browser or in tests. ## Turn it on Call it before your first SDK call, and only outside Lia: ```ts import { init, isLia, mockLia } from '@lia-labs/sdk'; if (!isLia()) mockLia(); const me = await init(); // { player: { name: 'tester' }, mode: 'test', ... } ``` With a framework package it's the same import, from that package. Keep it out of production builds with your bundler's dev flag if you like (`if (import.meta.env.DEV && !isLia())`). ## What it answers | Call | The mock answers | | --- | --- | | `init` | A test player (`tester`), test mode, English, light, a sheet, notifications available | | `pay` | A paid charge, `ch_mock0001` and up | | `showAd` and its shortcuts | A watched video (`ad_mock`), or a shown interstitial | | `confirm`, `showPopup` | Yes, and the first button | | `requestNotifications`, `inviteFriend` | Allowed, invited | | `submitScore`, `leaderboard` | Keeps your best score and ranks you first | | Storage | Kept in memory for the page's life | | `biometric` | Available Face ID, authenticated | | Everything else | What Lia answers when it works | ## Change its answers Pass `init` to change who and how, and `onMethod` to decide any answer yourself. Return a value to answer, throw `{ code }` to fail, or call `next()` for the mock's own answer: ```ts mockLia({ init: { language: 'ar', rtl: true, theme: 'dark', startParam: 'room-k3x9' }, latency: 300, // ms before each answer, to see your loading states onMethod: (call, next) => { if (call.method === 'pay' && call.params.productId === 'gold-pack') throw { code: 'cancelled' }; if (call.method === 'showAd') return { watched: false, shown: false, reason: 'no_ad' }; return next(); }, }); ``` ## Send events The mock can send any of Lia's events, to try how your app reacts: ```ts const mock = mockLia(); mock.event('theme', { colorScheme: 'dark', theme: { bg_color: '#09090B', text_color: '#F5F5F7' } }); mock.event('soundChanged', { on: false }); mock.event('startParam', { startParam: 'room-a81c' }); mock.event('mainButtonClicked'); mock.event('pause'); ``` `mock.calls` lists every call your page made, and `mock.restore()` removes the mock. ## In tests The same mock makes tests simple (here with Vitest and jsdom): ```ts import { afterEach, expect, it } from 'vitest'; import { init, mainButton, mockLia, resetForTests, resetStateForTests } from '@lia-labs/sdk'; afterEach(() => { resetForTests(); resetStateForTests(); }); it('shows Buy once the player is in', async () => { const mock = mockLia(); await init(); mainButton.setParams({ text: 'Buy', isVisible: true }); await Promise.resolve(); expect(mock.calls.at(-1)).toMatchObject({ method: 'bottomButton', params: { text: 'Buy', visible: true } }); mock.restore(); }); ``` ## See every message `setDebug(true)` logs every call, answer and event to the console, inside Lia too: ```ts import { setDebug } from '@lia-labs/sdk'; setDebug(true); // [lia] call pay { productId: 'extra-lives' } // [lia] event pause undefined // [lia] answer pay { id: 7, ok: true, result: { chargeId: 'ch_…', productId: 'extra-lives' } } ``` Pass a second argument to send them to your own logger, which helps inside Lia where there's no web inspector: `setDebug(true, { log: send, warn: send, error: send })`. ## Your server The mock's session (`lias_mock`) isn't one Lia signed, so `/session/verify` says it isn't valid. Give your server a development path, or try server flows inside Lia as a tester. See [Testing your app](/guides/testing). # @lia-labs/sdk Source: https://docs.lialabs.org/packages/sdk ```sh npm install @lia-labs/sdk ``` ## Start ```ts import { init, isLia, mockLia, ready } from '@lia-labs/sdk'; if (!isLia()) mockLia(); // building in a desktop browser const me = await init(); render(me); ready(); ``` `init(options?)` calls Lia once and answers with the same result as [`Lia.init()`](/reference/sdk-lifecycle#lia-init); calling it again answers with the first result. It also: - keeps the `--lia-*` [CSS variables](/concepts/look-and-feel#lias-theme) current on your page (turn off with `{ cssVars: false }`, or call `bindCssVars()` yourself), - sets your page's `lang` and `dir` from the person's language (turn off with `{ documentLanguage: false }`), - keeps the SDK's [state](/packages#state-you-can-watch) current from Lia's events. ## From the script tag to imports Everything on the script tag's `Lia` object is a named export here, with the same arguments, answers and error codes. Import only what you use; the rest is left out of your bundle. | Script tag | @lia-labs/sdk | | --- | --- | | `Lia.init()`, `Lia.ready()`, `Lia.session()`, `Lia.close()` | `init()`, `ready()`, `session()`, `close()` | | `Lia.pay(id, opts)`, `Lia.charges()`, `Lia.onCharge(fn)` | `pay(id, opts)`, `charges()`, `onCharge(fn)` | | `Lia.showAd(opts)` and its shortcuts, `Lia.onAd(fn)` | `showAd(opts)`, `showRewardedAd()`, `showRewardedInterstitialAd()`, `showInterstitialAd()`, `onAd(fn)` | | `Lia.requestNotifications()` | `requestNotifications()` | | `Lia.inviteFriend(opts)`, `Lia.inviteLink(p)`, `Lia.share(text)` | `inviteFriend(opts)`, `inviteLink(p)`, `share(text)` | | `Lia.confirm`, `Lia.showPopup`, `Lia.showAlert`, `Lia.showConfirm`, `Lia.toast` | The same names | | `Lia.expand()`, `Lia.requestFullscreen()`, `Lia.exitFullscreen()` | The same names | | `Lia.enableExpand()` … `Lia.disableClosingConfirmation()` | The same names | | `Lia.setBackgroundColor(c)`, `Lia.setHeaderColor(c)` | The same names | | `Lia.mainButton`, `Lia.secondaryButton`, `Lia.backButton`, `Lia.settingsButton` | `mainButton`, `secondaryButton`, `backButton`, `settingsButton` | | `Lia.haptic`, `Lia.scanQr`, `Lia.copyText`, `Lia.readClipboard`, `Lia.openLink`, `Lia.openLiaLink` | The same names | | `Lia.submitScore`, `Lia.leaderboard` | The same names | | `Lia.cloudStorage`, `Lia.deviceStorage`, `Lia.secureStorage`, `Lia.biometric` | The same names | | `Lia.accelerometer`, `Lia.gyroscope`, `Lia.deviceOrientation` | The same names, with `reading` and `isStarted` to watch | | `Lia.on(event, fn)`, `Lia.off`, `Lia.onPause`, `Lia.onResume` | `on(event, fn)`, `off`, `once`, `onAny`, `onPause`, `onResume` | | `Lia.language`, `Lia.theme`, `Lia.viewport` and the other properties | `language.get()`, `themeParams.get()`, `viewport.get()` and the rest | Every method's arguments, answers and errors are in the [SDK reference](/reference/sdk). ## Watching state ```ts import { colorScheme, language, viewport } from '@lia-labs/sdk'; language.get(); // "en" const stop = colorScheme.subscribe((scheme, previous) => repaint(scheme)); viewport.subscribe(({ height }) => resizeCanvas(height)); ``` A listener hears changes only (never on subscribe) and `subscribe` returns its own stop function. You can make your own with `signal(initial)` and `computed(sources, fn)`. ## Buttons The buttons work as on the script tag, and also tell you their state: ```ts import { mainButton } from '@lia-labs/sdk'; mainButton.setParams({ text: 'Buy for 5 LIA', isVisible: true }); const stop = mainButton.onClick(buy); mainButton.state.subscribe((s) => console.log(s.isProgressVisible)); ``` `setParams` takes camelCase names: `text`, `color`, `textColor`, `isVisible`, `isActive`, `isProgressVisible`, `hasShineEffect`, `position`. `onClick` returns a function that stops listening. `backButton.isVisible` and `settingsButton.isVisible` can be watched too. To show a button only while part of your page is on screen, `controlBottomButton(button, options)` and `controlHeaderButton(button, onClick)` give you `update(…)` and `dispose()` (dispose hides it). The framework packages use these. ## Errors Every failure is a `LiaError` with a `code`, the same codes as the [script tag](/reference/errors). `isLiaError(error, 'cancelled')` checks one. ```ts import { isLiaError, pay } from '@lia-labs/sdk'; try { await pay('extra-lives', { idempotencyKey: orderId }); } catch (error) { if (isLiaError(error, 'cancelled')) return; throw error; } ``` Arguments are checked before anything reaches Lia, and those checks reject the promise (or throw, for the few calls that don't return one). ## Without a bundler The package also has a script build that puts everything on `window.liaSdk`: ```html ``` Use this or the original script tag on a page, not both. ## TypeScript Everything is typed, including every method's params and answer, and every event's data (`Methods`, `Events`, `InitResult`, `ThemeParams`, `Charge`, `AdResult` and the rest are exported). # @lia-labs/sdk-react Source: https://docs.lialabs.org/packages/react ```sh npm install @lia-labs/sdk @lia-labs/sdk-react ``` React 18 or 19. Everything from [`@lia-labs/sdk`](/packages/sdk) is re-exported, so import it all from here. ## An app ```tsx import { useEffect, useState } from 'react'; import { isLia, mockLia, pay, ready, useBackButton, useClosingConfirmation, useInit, useLanguage, useMainButton } from '@lia-labs/sdk-react'; if (!isLia()) mockLia(); // building in a desktop browser export function App() { const lia = useInit(); const language = useLanguage(); const [screen, setScreen] = useState<'home' | 'game'>('home'); useEffect(() => { if (lia.status === 'ready') ready(); }, [lia.status]); useMainButton(screen === 'home' && { text: 'Buy extra lives', onClick: () => pay('extra-lives') }); useBackButton(screen === 'game' && (() => setScreen('home'))); useClosingConfirmation(screen === 'game'); if (lia.status === 'loading') return null; if (lia.status === 'error') return

    Open this app inside Lia.

    ; return

    Hello, @{lia.result.player.name} ({language})

    ; } ``` ## Hooks | Hook | Gives you | | --- | --- | | `useInit(options?)` | `{ status: "loading" | "ready" | "error", result, error }`. Calls `init()` once. | | `useSignal(value)` | Any SDK value as state (built on `useSyncExternalStore`). | | `usePlayer()`, `useMode()` | The player, and test or live. | | `useLanguage()`, `useRtl()` | The person's language. | | `useColorScheme()`, `useThemeParams()` | Light or dark, and Lia's colours. | | `useViewport()`, `useSafeArea()`, `useContentSafeArea()`, `useIsFullscreen()` | Layout. | | `useStartParam()`, `useSoundOn()`, `useNotificationsAllowed()` | The rest of Lia's state. | | `useLiaEvent(event, handler)` | Listens to one of Lia's events while mounted (always calls your latest handler). | | `useMainButton(options)`, `useSecondaryButton(options)` | Shows the button while mounted. `options` is `{ text, onClick, isActive, isProgressVisible, color, textColor, position }`; pass `null` or `false` to hide it. Your latest `onClick` is always used. | | `useBackButton(onBack)` | Shows the header's back button while mounted; `null` or `false` hides it. | | `useSettingsButton(onOpen)` | Settings in the ⋯ menu while mounted. | | `useClosingConfirmation(enabled?)` | Asks before closing while enabled. | | `useVerticalSwipesDisabled(disabled?)` | A swipe down won't close the sheet while disabled. | ## Busy states ```tsx const [busy, setBusy] = useState(false); useMainButton({ text: 'Buy', isProgressVisible: busy, onClick: async () => { setBusy(true); try { await buy(); } finally { setBusy(false); } }, }); ``` ## Server rendering The hooks read their first value straight from the SDK, so they render on a server too (with defaults: English, light, not initialized). `init()` only runs in the browser, in an effect. # @lia-labs/sdk-preact Source: https://docs.lialabs.org/packages/preact ```sh npm install @lia-labs/sdk @lia-labs/sdk-preact ``` Preact 10. Everything from [`@lia-labs/sdk`](/packages/sdk) is re-exported. The hooks are the [React package's](/packages/react#hooks), built on `preact/hooks` (no `preact/compat` needed): `useInit`, `useSignal`, `usePlayer`, `useLanguage`, `useColorScheme`, `useThemeParams`, `useViewport`, `useSafeArea`, `useStartParam`, `useSoundOn`, `useLiaEvent`, `useMainButton`, `useSecondaryButton`, `useBackButton`, `useSettingsButton`, `useClosingConfirmation`, `useVerticalSwipesDisabled` and the rest. ```tsx import { isLia, mockLia, pay, useBackButton, useInit, useMainButton } from '@lia-labs/sdk-preact'; if (!isLia()) mockLia(); function Shop({ onBack }: { onBack: () => void }) { useBackButton(onBack); useMainButton({ text: 'Buy extra lives', onClick: () => pay('extra-lives') }); return

    3 extra lives, 5 LIA

    ; } ``` # @lia-labs/sdk-vue Source: https://docs.lialabs.org/packages/vue ```sh npm install @lia-labs/sdk @lia-labs/sdk-vue ``` Vue 3.3 or later. Everything from [`@lia-labs/sdk`](/packages/sdk) is re-exported. Each composable stops listening when its component (or effect scope) goes away. ## An app ```vue ``` ## Composables | Composable | Gives you | | --- | --- | | `useInit(options?)` | `{ status, result, error }` as refs. Calls `init()` once. | | `useSignal(value)` | Any SDK value as a read-only ref. | | `usePlayer()`, `useMode()`, `useLanguage()`, `useRtl()`, `useColorScheme()`, `useThemeParams()`, `useViewport()`, `useSafeArea()`, `useContentSafeArea()`, `useIsFullscreen()`, `useStartParam()`, `useSoundOn()`, `useNotificationsAllowed()` | Lia's state as read-only refs. | | `useLiaEvent(event, handler)` | Listens to one of Lia's events. | | `useMainButton(options)`, `useSecondaryButton(options)` | Shows the button. `options` can be an object, a ref or a getter, and the button follows it; `null` or `false` hides it. | | `useBackButton(onBack)`, `useSettingsButton(onOpen)` | Shows the header button. Pass a function, or a ref to one (`null` hides it). | | `useClosingConfirmation(enabled?)`, `useVerticalSwipesDisabled(disabled?)` | `enabled` can be a boolean, a ref or a getter. | > **Note** Header buttons take a function or a ref, never a getter: a handler is a function itself, so a getter would be mistaken for it. # @lia-labs/sdk-svelte Source: https://docs.lialabs.org/packages/svelte ```sh npm install @lia-labs/sdk @lia-labs/sdk-svelte ``` Svelte 4 or 5. Everything from [`@lia-labs/sdk`](/packages/sdk) is re-exported. ## An app ```svelte {#if $lia.status === 'ready'}

    Hello, @{$lia.result.player.name} ({$language}, {$colorScheme})

    {/if} ``` ## Stores and helpers | Export | Gives you | | --- | --- | | `initStore(options?)` | A store of `{ status, result, error }` that calls `init()`. | | `stores` | Lia's state as stores: `player`, `mode`, `language`, `rtl`, `colorScheme`, `themeParams`, `viewport`, `safeArea`, `contentSafeArea`, `isFullscreen`, `startParam`, `soundOn`, `notificationsAllowed`, `initialized`. | | `toStore(value)` | Any SDK value as a store. | | `useLiaEvent(event, handler)` | Listens to one of Lia's events for the component's life; returns a stop function. | | `useMainButton(options)`, `useSecondaryButton(options)` | Shows the button for the component's life. Returns `{ update(options), dispose() }`. | | `useBackButton(onBack)`, `useSettingsButton(onOpen)` | Shows the header button for the component's life. Returns `{ update, dispose }`. | | `useClosingConfirmation()`, `useVerticalSwipesDisabled()` | For the component's life; return a function that ends it early. | Call the `use*` helpers while a component is being set up so they end with it. Called anywhere else (an event handler, a module), they work the same but you call `dispose()` yourself: ```ts let back: { dispose(): void } | null = null; function openDetail() { back = useBackButton(closeDetail); } function closeDetail() { back?.dispose(); } ``` # @lia-labs/sdk-solid Source: https://docs.lialabs.org/packages/solid ```sh npm install @lia-labs/sdk @lia-labs/sdk-solid ``` Solid 1.8 or later. Everything from [`@lia-labs/sdk`](/packages/sdk) is re-exported. Each primitive cleans up with its owner (a component or a root). ## An app ```tsx import { createEffect, createSignal, Show } from 'solid-js'; import { createBackButton, createInit, createMainButton, isLia, mockLia, pay, ready, useLanguage } from '@lia-labs/sdk-solid'; if (!isLia()) mockLia(); function Game(props: { onBack: () => void }) { createBackButton(props.onBack); // shown while this component lives return

    Playing…

    ; } export function App() { const lia = createInit(); const language = useLanguage(); const [playing, setPlaying] = createSignal(false); createEffect(() => lia().status === 'ready' && ready()); createMainButton(() => !playing() && { text: 'Buy extra lives', onClick: () => pay('extra-lives') }); return (

    Hello ({language()})

    setPlaying(true)}>Play}> setPlaying(false)} />
    ); } ``` ## Primitives | Export | Gives you | | --- | --- | | `createInit(options?)` | An accessor of `{ status, result, error }`. Calls `init()` once. | | `useSignal(value)` | Any SDK value as an accessor. | | `usePlayer()`, `useMode()`, `useLanguage()`, `useRtl()`, `useColorScheme()`, `useThemeParams()`, `useViewport()`, `useSafeArea()`, `useContentSafeArea()`, `useIsFullscreen()`, `useStartParam()`, `useSoundOn()`, `useNotificationsAllowed()` | Lia's state as accessors. | | `createLiaEvent(event, handler)` | Listens to one of Lia's events. | | `createMainButton(options)`, `createSecondaryButton(options)` | Shows the button. `options` can be an object or an accessor, and the button follows it; `null` or `false` hides it. | | `createBackButton(onBack)`, `createSettingsButton(onOpen)` | Shows the header button. | | `createClosingConfirmation()`, `createVerticalSwipesDisabled()` | For the owner's life. | # @lia-labs/sdk-angular Source: https://docs.lialabs.org/packages/angular ```sh npm install @lia-labs/sdk @lia-labs/sdk-angular ``` Angular 17 or later. Everything from [`@lia-labs/sdk`](/packages/sdk) is re-exported. The `inject*` functions run in an injection context (a component's constructor or field initializers, or `runInInjectionContext`) and clean up when it's destroyed. ## An app ```ts import { Component, effect } from '@angular/core'; import { injectBackButton, injectInit, injectLanguage, injectMainButton, isLia, mockLia, pay, ready } from '@lia-labs/sdk-angular'; if (!isLia()) mockLia(); @Component({ selector: 'app-root', standalone: true, template: ` @if (lia().status === 'ready') {

    Hello, @{{ lia().result?.player?.name }} ({{ language() }})

    } `, }) export class AppComponent { lia = injectInit(); language = injectLanguage(); mainButton = injectMainButton({ text: 'Buy extra lives', onClick: () => pay('extra-lives') }); constructor() { effect(() => { if (this.lia().status === 'ready') ready(); }); } } ``` ## Functions | Function | Gives you | | --- | --- | | `injectInit(options?)` | A signal of `{ status, result, error }`. Calls `init()` once. | | `toLiaSignal(value)` | Any SDK value as a read-only signal. | | `injectPlayer()`, `injectMode()`, `injectLanguage()`, `injectRtl()`, `injectColorScheme()`, `injectThemeParams()`, `injectViewport()`, `injectSafeArea()`, `injectContentSafeArea()`, `injectIsFullscreen()`, `injectStartParam()`, `injectSoundOn()`, `injectNotificationsAllowed()` | Lia's state as read-only signals. | | `injectLiaEvent(event, handler)` | Listens to one of Lia's events. | | `injectMainButton(options)`, `injectSecondaryButton(options)` | Shows the button for the component's life. Returns `{ update(options), dispose() }`. | | `injectBackButton(onBack)`, `injectSettingsButton(onOpen)` | Shows the header button for the component's life. | | `injectClosingConfirmation()`, `injectVerticalSwipesDisabled()` | For the component's life. | To follow a signal, update the button in an effect: ```ts busy = signal(false); button = injectMainButton({ text: 'Buy', onClick: () => this.buy() }); constructor() { effect(() => this.button.update({ text: 'Buy', isProgressVisible: this.busy(), onClick: () => this.buy() })); } ``` # @lia-labs/elements Source: https://docs.lialabs.org/packages/elements ```sh npm install @lia-labs/elements ``` Or without a build step: ```html ``` The script build registers the elements as it loads and puts the whole SDK on `window.liaElements`. With a bundler, register them once: ```ts import { defineLiaElements } from '@lia-labs/elements'; defineLiaElements(); ``` ## An app ```html
    ``` The elements draw nothing on your page: each one controls a part of Lia while it's in the document. Removing it (or a parent) hides the button or ends the hold. Elements inside a `hidden` parent still count: add and remove them instead. ## Elements | Element | While it's on the page | Attributes | Events | | --- | --- | --- | --- | | `` | Calls `init()`, then sets `ready` (or `error`) on itself | `auto-ready` calls `ready()` after the first frame; `no-css-vars` | `lia-ready` (detail: the init result), `lia-error` | | `` | Lia's main button | `text`, `color`, `text-color`, `disabled`, `progress`, `shine` | `lia-click` | | `` | The secondary button | The same, and `position` | `lia-click` | | `` | The header's back button | | `lia-click` | | `` | Settings in the ⋯ menu | | `lia-click` | | `` | Lia asks before closing | | | | `` | A swipe down won't close the sheet | | | Changing an attribute updates the button: `button.setAttribute('progress', '')` shows its spinner, `button.toggleAttribute('disabled', true)` greys it out. # @lia-labs/bridge Source: https://docs.lialabs.org/packages/bridge Most apps want [`@lia-labs/sdk`](/packages/sdk), which is built on this and re-exports all of it. Use the bridge on its own to build your own layer, or to call something the SDK doesn't wrap. ```sh npm install @lia-labs/bridge ``` ## The protocol Inside Lia, your page runs in the Lia app's web view. A call is a JSON message the page posts with `window.ReactNativeWebView.postMessage`: ```json { "lia": 1, "id": 7, "method": "haptic", "params": { "kind": "success" } } ``` Lia answers each call once, through `window.__lia.receive(...)`, with `{ id, ok: true, result }` or `{ id, ok: false, error: { code, message? } }`, and sends events the same way as `{ event, data }`. The bridge defines `window.__lia` (frozen, once per page) and matches answers to calls by id. ## Calling ```ts import { isLia, request, send } from '@lia-labs/bridge'; isLia(); // true inside the Lia app (or with mockLia) const me = await request('init'); const { best } = await request('submitScore', { score: 1280 }); send('ready'); // without waiting for the answer; failures are dropped ``` `request(method, params?, options?)` is typed per method: the params it takes and the answer it resolves with come from the `Methods` type. It rejects with a `LiaError`: | Code | When | | --- | --- | | `not_in_lia` | There's no bridge (not inside Lia, no mock). | | `timeout` | Lia didn't answer within 20 seconds (or your `timeout`). Calls that wait on the person (`pay`, ads, dialogs, the scanner, Face ID…) have no limit unless you set one. | | `aborted` | Your `signal` was aborted. Lia may still act on the call. | | `too_large` | The message is over 16,000 characters, which Lia would ignore. | | Lia's own codes | Whatever Lia answered: see [Error codes](/reference/errors). | ```ts const controller = new AbortController(); const code = request('scanQr', { text: 'Scan a table' }, { signal: controller.signal, timeout: 60_000 }); ``` ## Listening ```ts import { on, once, onAny, off } from '@lia-labs/bridge'; const stop = on('theme', ({ colorScheme, theme }) => repaint(colorScheme, theme)); once('resume', () => refresh()); onAny((event, data) => console.log(event, data)); stop(); ``` Events are typed (`Events`): `pause`, `resume`, `theme`, `viewport`, `fullscreenChanged`, `languageChanged`, `startParam`, `notificationsChanged`, `soundChanged`, `charge`, `ad`, the four `*ButtonClicked` events, and `sensor` (`{ name, values }`; the SDK turns it into each sensor's `reading`). A listener that throws is logged and doesn't stop the others. ## Everything it exports | Export | | | --- | --- | | `isLia()` | Whether a bridge is there. | | `request(method, params?, options?)` | A typed call. | | `send(method, params?)` | A call without waiting. | | `on`, `off`, `once`, `onAny` | Events. | | `mockLia(options?)`, `MOCK_INIT` | A pretend Lia: see [Local development](/packages/local-development). | | `emit(message)` | Hands a message to the page as if Lia sent it (for mocks and tests). | | `setDebug(on, logger?)` | Logs every call, answer and event. | | `install()` | Starts listening now; says whether `window.__lia` is this package's. | | `LiaError`, `isLiaError(error, code?)` | Errors. | | `PROTOCOL`, `DEFAULT_TIMEOUT`, `MAX_MESSAGE`, `PATIENT` | The protocol's version and limits. | | `resetForTests()` | Forgets every call and listener. | | Types | `Methods`, `MethodName`, `ParamsOf`, `ResultOf`, `Events`, `EventName`, `InitResult`, `ThemeParams` and every shape Lia sends. | ## One per page Two copies of the bridge in one bundle share one set of calls and listeners (kept on a global symbol), so nothing breaks. The script tag SDK can't share: if it's already on the page, the bridge warns and can't hear Lia's answers. Use one or the other. # SDK overview Source: https://docs.lialabs.org/reference/sdk ## Loading it ```html ``` Load it from Lia, before your own scripts. It defines one global, `window.Lia`, once (loading it twice does nothing). It's plain JavaScript with no dependencies, and works with any framework or none. This page describes version 1.6.0. In TypeScript, declare the global until there's a types package: ```ts declare global { interface Window { Lia: any } } const Lia = window.Lia; ``` ## How calls answer - **Most methods return a promise.** It resolves with the answer described for each method, or rejects with an `Error` that has a `code` (and a `message` in English for you, not for players). - **Bad arguments reject too**, with a code like `bad_text`, before anything reaches Lia. A few methods that don't return promises (`Lia.inviteLink`, `Lia.on`, the buttons' setters) throw instead. - **Outside the Lia app**, every call rejects with `not_in_lia`. `Lia.available` tells you up front. - **Lia must answer within 20 seconds**, or the call rejects with `timeout`. Calls that wait on the person (the pay sheet, ads, dialogs, the friend picker, the notifications ask, the scanner, the clipboard, Face ID, share) wait as long as it takes. ```js try { const result = await Lia.pay("extra-lives"); } catch (error) { switch (error.code) { case "cancelled": return; // they changed their mind case "not_in_lia": return showOpenInLia(); default: Lia.toast("Something went wrong", { tone: "error" }); } } ``` Every code is listed in [Error codes](/reference/errors). ## Properties Read these after [`Lia.init()`](/reference/sdk-lifecycle#lia-init) has answered; before that they hold defaults. Most are kept current by Lia's events. | Property | Type | What it is | | --- | --- | --- | | `Lia.version` | string | The SDK's version, for example `"1.6.0"`. | | `Lia.available` | boolean | `true` inside the Lia app. Known as soon as the script loads. | | `Lia.platform` | string | `"ios"` or `"android"` (`"unknown"` before `init()`). | | `Lia.language` | string | The person's language in Lia: `en`, `es`, `pt`, `fr`, `tr`, `ru`, `vi`, `id`, `fil`, `sw`, `hi`, `ar` or `zh`. See [Language](/concepts/look-and-feel#language). | | `Lia.rtl` | boolean | Whether that language reads right to left (Arabic). | | `Lia.colorScheme` | string | `"light"` or `"dark"`. | | `Lia.theme` | object | Lia's colours, as `bg_color`, `text_color` and the rest. See [Lia's theme](/concepts/look-and-feel#lias-theme). | | `Lia.viewport` | object | `{ height, expanded }`: your page's height in px, and whether a sheet is at full height. | | `Lia.safeArea` | object | `{ top, bottom, left, right }`: the phone's safe area in px. | | `Lia.contentSafeArea` | object | `{ top, bottom, left, right }`: also clear of Lia's floating controls in fullscreen. | | `Lia.display` | string | How your app opened: `"sheet"`, `"full"` or `"fullscreen"`. | | `Lia.isFullscreen` | boolean | Whether it's edge to edge right now. | | `Lia.startParam` | string or null | What it was opened with: an invite's, a link's or a notification's startParam. `null` when opened from the Apps Center. | | `Lia.soundOn` | boolean | Whether the person wants your app's sounds. | | `Lia.notificationsAllowed` | boolean | Whether this person lets your app send them notifications. | ### Lia.isVersionAtLeast(version) Whether the SDK is at least this version, for features added later. ```js if (Lia.isVersionAtLeast("1.3")) Lia.inviteFriend({ startParam: room }); ``` | Parameter | Type | | | --- | --- | --- | | `version` | string | Like `"1.3"` or `"1.3.0"`. | Returns a boolean (not a promise). ## CSS variables After `init()`, the SDK sets these on your page's root and keeps them current: Lia's theme (`--lia-bg-color` and the rest), `--lia-color-scheme`, `--lia-viewport-height`, `--lia-safe-area-inset-*` and `--lia-content-safe-area-inset-*`. See [Look and feel](/concepts/look-and-feel). ## Every method | Area | Methods | | --- | --- | | [Startup and lifecycle](/reference/sdk-lifecycle) | `Lia.init`, `Lia.ready`, `Lia.session`, `Lia.close`, `Lia.on`, `Lia.off`, `Lia.onPause`, `Lia.onResume` | | [Payments and ads](/reference/sdk-payments-and-ads) | `Lia.pay`, `Lia.charges`, `Lia.onCharge`, `Lia.showAd`, `Lia.showRewardedAd`, `Lia.showRewardedInterstitialAd`, `Lia.showInterstitialAd`, `Lia.onAd` | | [Notifications and invites](/reference/sdk-notifications-and-invites) | `Lia.requestNotifications`, `Lia.inviteFriend`, `Lia.inviteLink`, `Lia.share` | | [Dialogs](/reference/sdk-dialogs) | `Lia.confirm`, `Lia.showPopup`, `Lia.showAlert`, `Lia.showConfirm`, `Lia.toast` | | [Layout, colours and buttons](/reference/sdk-layout) | `Lia.expand`, `Lia.requestFullscreen`, `Lia.exitFullscreen`, `Lia.enableExpand`, `Lia.disableExpand`, `Lia.enableVerticalSwipes`, `Lia.disableVerticalSwipes`, `Lia.enableClosingConfirmation`, `Lia.disableClosingConfirmation`, `Lia.setBackgroundColor`, `Lia.setHeaderColor`, `Lia.mainButton`, `Lia.secondaryButton`, `Lia.backButton`, `Lia.settingsButton` | | [Device](/reference/sdk-device) | `Lia.haptic`, `Lia.scanQr`, `Lia.copyText`, `Lia.readClipboard`, `Lia.openLink`, `Lia.openLiaLink`, `Lia.biometric`, `Lia.accelerometer`, `Lia.gyroscope`, `Lia.deviceOrientation` | | [Scores and storage](/reference/sdk-scores-and-storage) | `Lia.submitScore`, `Lia.leaderboard`, `Lia.cloudStorage`, `Lia.deviceStorage`, `Lia.secureStorage` | ## Coming from Telegram Lia's SDK follows Telegram's Mini Apps where it fits, so most ideas carry over: | Telegram | Lia | | --- | --- | | `Telegram.WebApp.initData` | `Lia.init()` answers with a session token your server verifies with [`POST /session/verify`](/reference/server-api#post-session-verify). | | `themeParams`, `--tg-theme-*` | `Lia.theme`, `--lia-*` (same names) | | `MainButton`, `SecondaryButton`, `BackButton`, `SettingsButton` | `Lia.mainButton`, `Lia.secondaryButton`, `Lia.backButton`, `Lia.settingsButton` | | `HapticFeedback` | `Lia.haptic(kind)` | | `CloudStorage`, `DeviceStorage`, `SecureStorage` | `Lia.cloudStorage`, `Lia.deviceStorage`, `Lia.secureStorage` (promises, not callbacks) | | `showPopup`, `showAlert`, `showConfirm` | The same, as promises | | `openInvoice` and Stars | `Lia.pay(productId)`, paid in $LIA | | `start_param` | `Lia.startParam` | # Startup and lifecycle Source: https://docs.lialabs.org/reference/sdk-lifecycle ### Lia.init() Call it first. It answers with who the player is and everything about how your app is shown, and fills in the SDK's [properties](/reference/sdk#properties) and CSS variables. ```js const me = await Lia.init(); ``` Resolves with: | Field | Type | What it is | | --- | --- | --- | | `player` | object | `{ id, name }`: the player's id in your app, and their Lia username. See [Players](/concepts/players-and-sessions). | | `session` | string | A session token for your server, `lias_…`. | | `exp` | number | When the session ends, in milliseconds since 1970 (15 minutes after opening). | | `mode` | string | `"test"` or `"live"`. | | `language` | string | The person's language in Lia, such as `"en"`. | | `rtl` | boolean | Whether it reads right to left. | | `platform` | string | `"ios"` or `"android"`. | | `theme` | string | `"light"` or `"dark"`. | | `themeParams` | object | Lia's colours (`bg_color`, `text_color`…). | | `display` | string | `"sheet"`, `"full"` or `"fullscreen"`. | | `isFullscreen` | boolean | Edge to edge or not. | | `viewport` | object | `{ height, expanded }` | | `safeArea` | object | `{ top, bottom, left, right }` in px | | `contentSafeArea` | object | `{ top, bottom, left, right }` in px | | `startParam` | string or null | What your app was opened with. | | `link` | string | Your app's invite link, without a startParam. | | `sound` | boolean | Whether the person wants your app's sounds. | | `notifications` | object | `{ allowed, available }`: whether they allowed your notifications, and whether your app may ask. | Errors: `not_in_lia`, `not_ready` (Lia is still opening the app; try again shortly), `timeout`. ### Lia.ready() Tells Lia your page is drawn, so it takes its loader away. Call it once your first screen is on the page. Returns nothing. ```js Lia.init().then(function (me) { renderHome(me); Lia.ready(); }); ``` ### Lia.session() A fresh session token for your server. Sessions last 15 minutes, so get a new one before calls your server must trust. ```js const { session, exp } = await Lia.session(); ``` Resolves with `{ session, exp }`. Errors: `not_open` (the person hasn't opened your app in Lia yet; call `init()` first), `timeout`. ### Lia.close() Closes your app. Resolves with `null`. If you turned on [closing confirmation](/reference/sdk-layout#lia-enableclosingconfirmation), this closes without asking. ### Lia.on(event, listener) Listens for one of Lia's events. Returns a function that stops listening. ```js const stop = Lia.on("theme", function (data) { redraw(data.colorScheme); }); // later stop(); ``` Throws `bad_listener` if `listener` isn't a function. A listener that throws doesn't stop the others. ### Lia.off(event, listener) Stops a listener added with `Lia.on`. ### Lia.onPause(listener) The same as `Lia.on("pause", listener)`. Returns a function that stops listening. ### Lia.onResume(listener) The same as `Lia.on("resume", listener)`. ## Events | Event | Data | When | | --- | --- | --- | | `"pause"` | none | Lia went to the background, or one of Lia's screens (the pay sheet, an ad, a dialog) covered your app. Pause timers and sound. | | `"resume"` | none | Back again. | | `"theme"` | `{ theme, colorScheme }` | The person switched Lia between light and dark. `theme` is the new colours. CSS variables are already updated. | | `"viewport"` | `{ height, expanded, isStateStable }` | Your page's height changed (a sheet pulled up, bottom buttons shown). | | `"fullscreenChanged"` | `{ isFullscreen, contentSafeArea }` | Your app went to or from fullscreen. | | `"languageChanged"` | `{ language, rtl }` | The person changed Lia's language. | | `"startParam"` | `{ startParam }` | A notification or invite for your app was opened while it was already open. | | `"notificationsChanged"` | `{ allowed }` | The person allowed or stopped your app's notifications. | | `"soundChanged"` | `{ on }` | The person muted or unmuted your app from Lia's ⋯ menu. | | `"charge"` | `{ chargeId, productId }` | A payment was confirmed while no `Lia.pay()` was waiting (after a reload). Also `Lia.onCharge`. | | `"ad"` | `{ state, format }` | An ad's stage: `"loading"`, `"showing"` or `"closed"`. Also `Lia.onAd`. | | `"mainButtonClicked"` | none | The main button was pressed. Also `Lia.mainButton.onClick`. | | `"secondaryButtonClicked"` | none | The secondary button was pressed. | | `"backButtonClicked"` | none | The header's back button was pressed. | | `"settingsButtonClicked"` | none | Settings was chosen in the ⋯ menu. | | `"accelerometerChanged"` | `{ x, y, z }` | A reading, while the accelerometer runs. | | `"gyroscopeChanged"` | `{ x, y, z }` | A reading, while the gyroscope runs. | | `"deviceOrientationChanged"` | `{ alpha, beta, gamma }` | A reading, while orientation runs. | | `"accelerometerStarted"`, `"accelerometerStopped"` | none | The sensor started or stopped. Also for `gyroscope` and `deviceOrientation`. | The SDK updates its own properties (`Lia.colorScheme`, `Lia.viewport`, `Lia.language`, `Lia.soundOn` and the rest) before your listeners run. # Payments and ads Source: https://docs.lialabs.org/reference/sdk-payments-and-ads ## Payments See [Payments and the pay sheet](/concepts/payments) for how they work, and [Sell an item](/guides/sell-an-item) for the whole flow. ### Lia.pay(productId, options) Opens Lia's pay sheet for one of your products. Resolves once it's paid; rejects if it isn't. ```js const { chargeId, productId } = await Lia.pay("extra-lives"); // With your own key (SDK 1.5): asking again with the same key charges once and answers with the same chargeId. const order = "order-" + crypto.randomUUID(); const paid = await Lia.pay("extra-lives", { idempotencyKey: order }); ``` | Parameter | Type | | | --- | --- | --- | | `productId` | string | One of your products' ids, up to 64 characters. | | `options.idempotencyKey` | string | Optional, 1 to 64 of letters, digits, `_`, `-`, `.` and `:`. See [Idempotency keys](#idempotency-keys). | Resolves with `{ chargeId, productId }`. Then send the `chargeId` to your server to deliver it. | Error | When | | --- | --- | | `cancelled` | The person closed the sheet without paying. | | `no_product` | No active product with that id. | | `busy` | Another of Lia's screens is open over your app. | | `bad_productId` | The id isn't text up to 64 characters. | | `bad_idempotency_key` | The key isn't in the right shape. | | `not_ready`, `not_in_lia` | Lia isn't ready, or this isn't Lia. | If the page reloads while the sheet is open, the charge arrives as a `"charge"` event instead (see `Lia.onCharge`). ### Idempotency keys From SDK 1.5, the calls that change something (`Lia.pay`, `Lia.inviteFriend`, `Lia.submitScore` and cloud storage's writes) take an optional `idempotencyKey`. The same key again, within 24 hours, doesn't do it twice: it answers as the first call did. - **Lia already keeps each call once** when it retries it itself (a dropped connection). Your own key adds the same safety across your page's retries and reloads: keep it (in `Lia.deviceStorage`, say) until the call is answered. - **Make one key per action** (`crypto.randomUUID()` is fine) and reuse it only to retry that action. - **The same key with different arguments** is refused. Use a new key for a new action. - For a payment, the pay sheet still opens for the person to confirm; confirming with a key that already paid answers with that first charge and charges nothing more. - A Lia app older than SDK 1.5's release ignores the key, so the call still works, just without your key. ### Lia.charges() This player's paid charges that your server hasn't delivered yet, oldest first, up to 50. Call it when your app opens, and deliver any you find. ```js const pending = await Lia.charges(); // [{ chargeId, productId }] for (const charge of pending) await deliverOnServer(charge.chargeId); ``` Resolves with an array of `{ chargeId, productId }`. ### Lia.onCharge(listener) Hears a charge that was confirmed while no `Lia.pay()` was waiting for it, such as after a reload. The listener gets `{ chargeId, productId }`. Returns a function that stops listening. The same as `Lia.on("charge", listener)`. ## Ads See [Ads](/concepts/ads) for the formats, pacing and checking on your server. ### Lia.showAd(options) Plays a full screen ad over your app. Resolves once it's closed. ```js const result = await Lia.showAd({ format: "rewarded" }); if (result.watched) giveReward(result.adId); ``` | Option | Type | | | --- | --- | --- | | `format` | string | `"rewarded"` (the default), `"rewardedInterstitial"` or `"interstitial"`. | | `onState` | function | Optional. Hears each stage: `"loading"`, `"showing"`, `"closed"`. | Resolves with: | Field | Type | | | --- | --- | --- | | `watched` | boolean | A rewarded ad was watched to the end. | | `shown` | boolean | An ad went on screen. | | `adId` | string | Present when `watched` is true, and when an interstitial was shown. Check it on your server with [`POST /ads/verify`](/reference/server-api#post-ads-verify). | | `reason` | string | When `watched` is false: `cancelled`, `skipped`, `no_ad`, `too_soon`, `failed` or a connection problem. | Errors: `bad_format`, `busy`. Not having an ad to show isn't an error: it resolves with `reason: "no_ad"`. ### Lia.showRewardedAd(options) The same as `Lia.showAd({ format: "rewarded", onState })`. A video the person chose to watch. ### Lia.showRewardedInterstitialAd(options) The same as `Lia.showAd({ format: "rewardedInterstitial", onState })`. Lia first asks the person, with Watch and Skip; Skip resolves with `reason: "skipped"`. ### Lia.showInterstitialAd(options) The same as `Lia.showAd({ format: "interstitial", onState })`. Paced by Lia: when it isn't its turn, it resolves straight away with `shown: false, reason: "too_soon"`. ```js // Between rounds: ask every time, and let Lia decide. await Lia.showInterstitialAd(); startNextRound(); ``` ### Lia.onAd(listener) Hears every ad's stages, `{ state, format }`, with `state` one of `"loading"`, `"showing"` and `"closed"`. Returns a function that stops listening. The same as `Lia.on("ad", listener)`. # Notifications, invites and the lobby Source: https://docs.lialabs.org/reference/sdk-notifications-and-invites ## Notifications Your server sends notifications with [`POST /notifications`](/reference/server-api#post-notifications). Your page only asks for permission. See [Notifications](/concepts/notifications). ### Lia.requestNotifications() Asks the person, in Lia's own sheet, to let your app send them notifications. If they already did, it answers at once without asking. ```js const { allowed } = await Lia.requestNotifications(); ``` Resolves with `{ allowed }`. `Lia.notificationsAllowed` is updated too. | Error | When | | --- | --- | | `notifications_off` | Your app may not send notifications (the Lia team turned them off for it). | | `busy` | Another of Lia's screens is open. | ## Invites See [Invites and links](/concepts/invites). ### Lia.inviteFriend(options) Opens Lia's friend picker. Each friend picked gets a card in their Lia chat that opens your app with your startParam. The sheet also lets the person share your invite link anywhere. ```js const { invited } = await Lia.inviteFriend({ startParam: "room-k3x9" }); ``` | Option | Type | | | --- | --- | --- | | `startParam` | string | Optional. 1 to 64 of letters, digits, `_` and `-`. | | `idempotencyKey` | string | Optional (SDK 1.5). With the same key, each friend gets that invite's card once. See [Idempotency keys](/reference/sdk-payments-and-ads#idempotency-keys). | Resolves with `{ invited }`: `true` once anyone was invited or the link was shared. Your app never learns who. Errors: `bad_start_param`, `bad_idempotency_key`, `busy`. ### Lia.inviteLink(startParam) Your app's invite link, with an optional startParam. Anyone can open it: with Lia, it opens your app with the startParam; without, it points to the App Store. Call it after `Lia.init()`. ```js const link = Lia.inviteLink("room-k3x9"); // "https://api.lialabs.org/play/your-app?start=room-k3x9" ``` Returns a string (not a promise). Throws `not_ready` before `init()` has answered, and `bad_start_param` for a startParam that doesn't fit. ### Lia.share(text) Opens the phone's share sheet with your text, for any app on the phone. Resolves with `null` once the sheet closes. ```js await Lia.share("I scored 1280 in Tilt Runner. Beat me: " + Lia.inviteLink()); ``` | Parameter | Type | | | --- | --- | --- | | `text` | string | Up to 500 characters. | Errors: `bad_text`. ## The lobby Public tables anyone on Lia can find (SDK 1.6). Your server lists and seats them; see [Public tables](/concepts/lobby). ### Lia.lobby #### Lia.lobby.tables(options) The open tables in your app, newest first. Never the player's own tables, full or expired ones, or those of anyone they blocked or who blocked them. ```js const { tables, next } = await Lia.lobby.tables({ stake: 5 }); // [{ id: "t8k2d", game: "your-app", host: { name: "amara" }, seats: 2, taken: 1, title: "Quick game", stake: 5, pot: 0, createdAt, expiresAt }] const more = next ? await Lia.lobby.tables({ stake: 5, cursor: next }) : null; ``` | Option | Type | | | --- | --- | --- | | `stake` | number | Optional. Only tables at this stake, in LIA (`0` for free ones). | | `cursor` | string | Optional. `next` from the page before. | | `limit` | number | Optional. 1 to 50, 20 by default. | Resolves with `{ tables, next }`: `next` is the cursor for more, or `null`. Each table: | Field | Type | | | --- | --- | --- | | `id` | string | The table's id, as your server listed it. | | `game` | string | Your app's id. | | `host` | object | `{ name }`: the host's Lia username. | | `seats`, `taken` | number | Seats in all, and taken (the host's included). | | `title` | string or null | Up to 40 characters. | | `stake`, `pot` | number | In LIA, as your server listed them (for show). | | `createdAt`, `expiresAt` | number | Milliseconds since 1970. | To sit, send the table's `id` to your server, which seats the player with [`POST /lobby/tables/:id/sit`](/reference/server-api#post-lobby-tables-id-sit). Someone who chose your table in Lia's own lobby arrives with `Lia.startParam` set to `lobby-
    `: seat them the same way. Errors: `bad_stake`, `bad_cursor`, `bad_session` (the session couldn't be renewed), `not_in_lia`. #### Lia.lobby.report(playerId, reason, options) Reports a player to the Lia team. They're also blocked for the person reporting, so neither sees the other's tables again. ```js await Lia.lobby.report("p_3f9c0a6d1b2e4f5a6b7c", "unfair", { tableId: "t8k2d", note: "Left every game when losing" }); ``` | Parameter | Type | | | --- | --- | --- | | `playerId` | string | The player's id in your app. | | `reason` | string | `"name"`, `"unfair"`, `"abuse"` or `"other"`. | | `options.tableId` | string | Optional. The table it happened at. | | `options.note` | string | Optional, up to 300 characters. | Resolves with `{ reported: true }`. Errors: `bad_reason`, `unknown_player`, `slow_down` (more than 20 reports in a day), `bad_note`, `bad_tableId`. # Dialogs Source: https://docs.lialabs.org/reference/sdk-dialogs Dialogs open over your app in Lia's own style and language direction, and pause your app while they're open. One of Lia's screens shows at a time: while one is open, another fails with `busy`. ### Lia.confirm(options) A yes or no question in Lia's own sheet. ```js const { confirmed } = await Lia.confirm({ title: "Start a new round?", body: "Your score so far won't count.", confirm: "Start over", danger: true, }); ``` | Option | Type | | | --- | --- | --- | | `title` | string | Required, up to 60 characters. | | `body` | string | Optional, up to 300 characters. | | `confirm` | string | Optional label for the yes button, up to 24 characters. Lia's own word when left out. | | `danger` | boolean | Optional. Shows the yes button as destructive. | Resolves with `{ confirmed }`, `false` however the sheet was closed without a yes. Errors: `bad_title`, `bad_body`, `bad_confirm`, `busy`. ### Lia.showPopup(params) A message with up to three buttons. Resolves with the pressed button's `id`, or `null` if it was closed another way. ```js const choice = await Lia.showPopup({ title: "Save your game?", message: "You can come back to it later.", buttons: [ { id: "save", type: "default", text: "Save" }, { id: "delete", type: "destructive", text: "Delete" }, { type: "cancel" }, ], }); ``` | Param | Type | | | --- | --- | --- | | `title` | string | Optional, up to 64 characters. | | `message` | string | Required, up to 256 characters. | | `buttons` | array | 1 to 3 buttons. One Close button when left out. | Each button: | Field | Type | | | --- | --- | --- | | `id` | string | What `showPopup` resolves with when it's pressed. Up to 64 characters; `""` when left out. | | `type` | string | `"default"`, `"ok"`, `"close"`, `"cancel"` or `"destructive"`. | | `text` | string | Required for `default` and `destructive`, up to 64 characters. The others use Lia's own word when left out. | Errors: `bad_message`, `bad_title`, `bad_buttons`, `bad_button_type`, `bad_text`, `bad_id`, `busy`. ### Lia.showAlert(message) A message with one Close button. Resolves with nothing once it's closed. ```js await Lia.showAlert("Your progress is saved."); ``` ### Lia.showConfirm(message) A message with OK and Cancel. Resolves with `true` when OK was pressed. ```js if (await Lia.showConfirm("Leave this match?")) leaveMatch(); ``` ### Lia.toast(message, options) A short message in Lia's own style that goes away by itself. It doesn't pause your app. ```js Lia.toast("Saved"); Lia.toast("No connection", { tone: "error", position: "top" }); ``` | Parameter | Type | | | --- | --- | --- | | `message` | string | Up to 140 characters. | | `options.position` | string | `"bottom"` (the default) or `"top"`. | | `options.tone` | string | `"info"` (the default) or `"error"`. | Resolves with `null`. Errors: `bad_text`, `bad_position`. # Layout, colours and buttons Source: https://docs.lialabs.org/reference/sdk-layout ## The sheet and fullscreen See [Three looks](/concepts/how-apps-run#three-looks) for `sheet`, `full` and `fullscreen`. ### Lia.expand() As a sheet: takes the full height of the screen. Resolves with `{ expanded: true }` (also when it already was). Your page hears a `"viewport"` event with the new height. ### Lia.requestFullscreen() Edge to edge, with only Lia's floating Close and ⋯ over your page. A sheet expands first. Resolves with `{ isFullscreen: true }`, and your page hears `"fullscreenChanged"` with the new `contentSafeArea`. Keep your content below `Lia.contentSafeArea.top`. ### Lia.exitFullscreen() Back from fullscreen: to a sheet if your app opened as one, otherwise to the whole screen under Lia's header. Resolves with `{ isFullscreen: false }`. ### Lia.disableExpand() As a sheet: keeps its height, so pulling it up no longer takes the whole screen. `Lia.expand()` still can. Resolves with `null`. ### Lia.enableExpand() As a sheet: pulling it up takes the whole screen again (the default). ### Lia.disableVerticalSwipes() As a sheet: a swipe down no longer closes it. Turn this off for games that use swipes. Resolves with `null`. ### Lia.enableVerticalSwipes() As a sheet: a swipe down closes it again (the default). ## Closing ### Lia.enableClosingConfirmation() Asks before closing, for unsaved work or a game under way: when the person closes your app with Close or from the ⋯ menu, Lia asks whether to leave first. A sheet can't be swiped closed while it's on. Resolves with `null`. ```js Lia.enableClosingConfirmation(); // when a match starts Lia.disableClosingConfirmation(); // when it ends ``` `Lia.close()` from your own page closes without asking. ### Lia.disableClosingConfirmation() Closes without asking again (the default). ## Colours ### Lia.setBackgroundColor(color) The colour behind your page, and behind the bottom buttons. Resolves with `null`. | Parameter | Type | | | --- | --- | --- | | `color` | string | `#RRGGBB`, like `"#E9EEFF"`. | Errors: `bad_color`. ### Lia.setHeaderColor(color) The colour of Lia's header over your app. Its text and buttons turn light or dark to suit. Resolves with `null`. Errors: `bad_color`. ```js Lia.setHeaderColor("#2F5BFF"); ``` ## Bottom buttons ### Lia.mainButton A wide button under your page, in Lia blue unless you colour it, like Telegram's MainButton. `Lia.secondaryButton` is a second one beside or above it, white (or Lia's card colour) with blue text. Both are hidden until you show them. ```js Lia.mainButton.setParams({ text: "Buy for 5 LIA", is_visible: true }); Lia.mainButton.onClick(async function () { Lia.mainButton.showProgress(); try { await buy("extra-lives"); } finally { Lia.mainButton.hideProgress(); } }); ``` Every method returns the button, so calls chain: `Lia.mainButton.setText("Next").show()`. | Method | What it does | | --- | --- | | `Lia.mainButton.setText(text)` | Its label, up to 64 characters. Throws `bad_text`. | | `Lia.mainButton.setParams(params)` | Several at once (below). | | `Lia.mainButton.show()` | Shows it. | | `Lia.mainButton.hide()` | Hides it. | | `Lia.mainButton.enable()` | Lets it be pressed. | | `Lia.mainButton.disable()` | Greys it out. | | `Lia.mainButton.showProgress(leaveActive)` | A spinner in place of its label. It's disabled while it spins, unless `leaveActive` is `true`. | | `Lia.mainButton.hideProgress()` | Its label again, and enabled. | | `Lia.mainButton.onClick(listener)` | Hears a press. | | `Lia.mainButton.offClick(listener)` | Stops hearing it. | `setParams` takes any of these (Telegram's names or camelCase): | Param | Type | | | --- | --- | --- | | `text` | string | Up to 64 characters. | | `color` | string or null | `#RRGGBB`, or `null` for Lia's. | | `text_color` or `textColor` | string or null | `#RRGGBB`, or `null` for Lia's. | | `is_visible` or `isVisible` | boolean | | | `is_active` or `isActive` | boolean | | | `has_shine_effect` or `hasShineEffect` | boolean | Accepted, but Lia doesn't draw a shine yet. | | `position` | string | For the secondary button: where it sits next to the main one, `"left"` (the default), `"right"`, `"top"` or `"bottom"`. | It throws `bad_text`, `bad_color`, `bad_text_color` or `bad_position` for values that don't fit. The button's state, to read: | Property | | | --- | --- | | `Lia.mainButton.type` | `"main"` or `"secondary"` | | `Lia.mainButton.text` | Its label (`"Continue"` for the main button and `"Cancel"` for the secondary until you set one) | | `Lia.mainButton.color`, `Lia.mainButton.textColor` | Your colours, or `null` | | `Lia.mainButton.isVisible`, `Lia.mainButton.isActive` | | | `Lia.mainButton.isProgressVisible` | Whether its spinner shows | | `Lia.mainButton.hasShineEffect` | | | `Lia.mainButton.position` | | ### Lia.secondaryButton The second bottom button. The same methods and properties as `Lia.mainButton`; its presses are the `"secondaryButtonClicked"` event. ```js Lia.secondaryButton.setParams({ text: "Skip", is_visible: true, position: "left" }).onClick(skip); ``` ## Header buttons ### Lia.backButton A back button at the start of Lia's header (in fullscreen, Lia's floating Close becomes a back button). Use it for your own screens: show it when the person goes one screen in, and go back when it's pressed. Lia doesn't navigate for you. ```js function openDetail(item) { showDetail(item); Lia.backButton.show(); } Lia.backButton.onClick(function () { showList(); Lia.backButton.hide(); }); ``` | Method or property | | | --- | --- | | `Lia.backButton.show()` | Shows it. Returns the button. | | `Lia.backButton.hide()` | Hides it. | | `Lia.backButton.onClick(listener)` | Hears a press (the `"backButtonClicked"` event). | | `Lia.backButton.offClick(listener)` | Stops hearing it. | | `Lia.backButton.isVisible` | Whether it's shown. | While the back button shows in fullscreen, people close your app from the ⋯ menu. ### Lia.settingsButton Adds Settings to your app's ⋯ menu. Pressing it is the `"settingsButtonClicked"` event. The same methods as `Lia.backButton`. ```js Lia.settingsButton.show().onClick(openSettings); ``` # Device Source: https://docs.lialabs.org/reference/sdk-device ## Haptics ### Lia.haptic(kind) A tap the person feels. Resolves with `null`. ```js Lia.haptic("success"); ``` | `kind` | Feels like | | --- | --- | | `"light"` (the default), `"medium"`, `"heavy"`, `"rigid"`, `"soft"` | A tap, from light to heavy | | `"success"`, `"warning"`, `"error"` | A result | | `"selection"` | A tick, for a choice changing | An unknown kind plays `"light"`. Each haptic is a round trip to Lia, so don't play one every frame of a game. ## Scanning and the clipboard ### Lia.scanQr(options) Opens Lia's QR scanner over your app. Resolves with the code's text, or `null` if the person closed it. ```js const text = await Lia.scanQr({ text: "Scan the code on the table" }); ``` | Option | Type | | | --- | --- | --- | | `text` | string | Optional, under the scanner's window, up to 64 characters. | Errors: `bad_text`, `busy`. ### Lia.copyText(text) Copies text to the clipboard, and Lia says it's copied. Resolves with `null`. | Parameter | Type | | | --- | --- | --- | | `text` | string | Up to 4096 characters. | Errors: `bad_text`, `unsupported`. ### Lia.readClipboard() The text on the clipboard, or `null`. On iPhone, the phone asks the person first. ```js const pasted = await Lia.readClipboard(); ``` ## Links ### Lia.openLink(url, options) Opens a web page. Your frame only shows your own pages, so use this for any other site. ```js Lia.openLink("https://example.com/terms"); // in Lia's in-app browser, over your app Lia.openLink("https://example.com", { external: true }); // outside Lia, in the phone's browser ``` | Parameter | Type | | | --- | --- | --- | | `url` | string | An https address, up to 2048 characters. | | `options.external` | boolean | Open it outside Lia. | Resolves with `null` once the in-app browser is closed (or straight away for `external`). Errors: `bad_url`. ### Lia.openLiaLink(screen) Opens one of Lia's own screens. Pages close your app first (asking, if you turned [closing confirmation](/reference/sdk-layout#lia-enableclosingconfirmation) on) and open as themselves; sheets open over your app, which stays. ```js Lia.openLiaLink("mine"); // Lia's mining sheet, over your app ``` | `screen` | Opens | Your app | | --- | --- | --- | | `"invite"` | Invite friends to Lia | Closes | | `"strides"` | Strides | Closes | | `"activity"` | The person's activity | Closes | | `"send"` | Send $LIA | Closes | | `"apps"` | The Apps Center | Closes | | `"streak"` | The check-in streak | Stays | | `"mine"` | Mining | Stays | | `"receive"` | Receive $LIA | Stays | Resolves with `{ closed }`: `true` if your app closed, `false` if it stayed (or the person chose to stay). Errors: `unknown_screen`, `bad_screen`. ## Face ID and Touch ID ### Lia.biometric Ask the person to confirm it's them, with the phone's Face ID, Touch ID or fingerprint, before something sensitive in your app. Your app only learns whether they passed. #### Lia.biometric.init() Whether the phone can do it. Resolves with `{ available, type }`: `available` is true when the phone has it and it's set up; `type` is `"face"`, `"finger"` or `"unknown"`. #### Lia.biometric.authenticate(options) Asks the person to confirm. Resolves with `{ authenticated }`. If they can't use biometrics, the phone may offer its passcode instead. ```js const { available } = await Lia.biometric.init(); if (available) { const { authenticated } = await Lia.biometric.authenticate({ reason: "Confirm it's you to open your vault" }); } ``` | Option | Type | | | --- | --- | --- | | `reason` | string | Optional, shown by the phone, up to 128 characters. Lia's own words when left out. | Errors: `unsupported`, `busy`, `bad_reason`. ## Motion Three sensors, each with `start`, `stop`, `isStarted` and its latest reading. Readings arrive as events while a sensor runs; it stops when your app closes. All three are optional on phones: `start()` fails with `unsupported` where there's none. | Sensor | Reading | Units | Event | | --- | --- | --- | --- | | `Lia.accelerometer` | `x`, `y`, `z` | m/s², gravity included | `"accelerometerChanged"` | | `Lia.gyroscope` | `x`, `y`, `z` | Rotation rate, radians a second | `"gyroscopeChanged"` | | `Lia.deviceOrientation` | `alpha`, `beta`, `gamma` | Angles in radians | `"deviceOrientationChanged"` | ```js await Lia.deviceOrientation.start({ refreshRate: 50 }); Lia.on("deviceOrientationChanged", function (r) { steer(r.gamma); // tilting left and right }); // later Lia.deviceOrientation.stop(); ``` ### Lia.accelerometer | Member | | | --- | --- | | `start(options)` | Starts readings. `options.refreshRate`: 20 to 1000 ms between readings, 1000 by default. Resolves with `true`; also fires `"accelerometerStarted"`. Errors: `bad_refresh_rate`, `unsupported`. | | `stop()` | Stops them. Resolves with `true`; also fires `"accelerometerStopped"`. | | `isStarted` | Whether it's running. | | `x`, `y`, `z` | The latest reading, `null` before the first. | ### Lia.gyroscope The same as `Lia.accelerometer`, with `x`, `y` and `z` rotation rates. ### Lia.deviceOrientation The same, with `alpha`, `beta` and `gamma`. # Scores and storage Source: https://docs.lialabs.org/reference/sdk-scores-and-storage ## Scores Each app has one leaderboard. Only a player's best score counts. ### Lia.submitScore(score, options) Records a score. If it beats the player's best, it becomes their best. ```js const { best } = await Lia.submitScore(1280); ``` | Parameter | Type | | | --- | --- | --- | | `score` | number | A whole number, 0 to 1,000,000,000,000. Higher is better. | | `options.idempotencyKey` | string | Optional (SDK 1.5). See [Idempotency keys](/reference/sdk-payments-and-ads#idempotency-keys). | Resolves with `{ best }`: the player's best score now. Errors: `bad_score`, `bad_idempotency_key`, `not_open`. > **Important** Scores come from your page, so a determined player could send any score. Fine for bragging rights; for prizes, decide the winners on your server. ### Lia.leaderboard() The top 50, and where this player stands. ```js const { top, me } = await Lia.leaderboard(); ``` Resolves with: ```json { "top": [ { "rank": 1, "name": "amara", "score": 4210, "me": false }, { "rank": 2, "name": "kofi", "score": 3990, "me": true } ], "me": { "rank": 2, "score": 3990 } } ``` `name` is the Lia username. Ties go to whoever got there first. `me` is `null` until the player has a score. ## Storage | | Where | Limits | For | | --- | --- | --- | --- | | `Lia.cloudStorage` | Lia's server, per player | 1024 keys, values up to 4096 characters | Progress and settings that follow the player to any phone | | `Lia.deviceStorage` | This phone, for your app | 5 MB in all | Caches and drafts | | `Lia.secureStorage` | This phone's secure store (the Keychain on iPhone) | 10 items, values up to 2048 characters | Tokens and secrets | Each app only ever sees its own keys. Keys are 1 to 128 of letters, digits, `_` and `-`; values are text (store JSON with `JSON.stringify`). Bad keys and values reject with `bad_key`, `bad_keys` or `bad_value`. ### Lia.cloudStorage On Lia's server, for this player in your app, on any phone. Test and live share it. ```js await Lia.cloudStorage.setItem("progress", JSON.stringify({ level: 7, coins: 120 })); const progress = JSON.parse((await Lia.cloudStorage.getItem("progress")) ?? "{}"); ``` | Method | Resolves with | | --- | --- | | `Lia.cloudStorage.setItem(key, value, options)` | `true`. Fails with `storage_full` at 1024 keys. | | `Lia.cloudStorage.getItem(key)` | The value, or `null`. | | `Lia.cloudStorage.getItems(keys)` | An object of the keys that exist and their values. 1 to 100 keys. | | `Lia.cloudStorage.removeItem(key, options)` | `true` | | `Lia.cloudStorage.removeItems(keys, options)` | `true`. 1 to 100 keys. | | `Lia.cloudStorage.getKeys()` | All of this player's keys, sorted. | The writes take an optional `{ idempotencyKey }` last (SDK 1.5), so a late retry of an old write never overwrites a newer value. See [Idempotency keys](/reference/sdk-payments-and-ads#idempotency-keys). Errors: `not_open` (call `init()` first), `storage_full`, `bad_item`, `bad_keys`, `bad_idempotency_key`. ### Lia.deviceStorage On this phone only, for your app. Gone if the person removes Lia or moves to another phone. | Method | Resolves with | | --- | --- | | `Lia.deviceStorage.setItem(key, value)` | `true`. Values up to 1,048,576 characters; everything your app keeps together (keys and values) up to 5 MB, or `storage_full`. | | `Lia.deviceStorage.getItem(key)` | The value, or `null`. | | `Lia.deviceStorage.removeItem(key)` | `true` | | `Lia.deviceStorage.clear()` | `true`. Removes all of your app's keys on this phone. | ### Lia.secureStorage In the phone's secure store (the Keychain on iPhone), for your app. | Method | Resolves with | | --- | --- | | `Lia.secureStorage.setItem(key, value)` | `true`. Values up to 2048 characters; up to 10 items, or `storage_full`. | | `Lia.secureStorage.getItem(key)` | The value, or `null`. | | `Lia.secureStorage.removeItem(key)` | `true` | | `Lia.secureStorage.clear()` | `true` | Your page's own `localStorage` also works, but it's tied to Lia's web view and may be cleared; prefer these. # Server API Source: https://docs.lialabs.org/reference/server-api ## Basics | | | | --- | --- | | Base address | `https://api.lialabs.org/api/apps/v1` | | Authentication | `Authorization: Bearer `, your test key (`lia_test_…`) or live key (`lia_live_…`) | | Bodies | JSON, with `Content-Type: application/json` | | Amounts | In $LIA, as numbers, to 6 decimal places | | Times | Milliseconds since 1970 (UTC) | Call it from your server only. Your key is a secret: never put it in your page. ```sh curl https://api.lialabs.org/api/apps/v1/pool \ -H "Authorization: Bearer $LIA_KEY" ``` A small helper used in the examples below: ```js title="lia.js" const LIA = "https://api.lialabs.org/api/apps/v1"; export async function lia(method, path, body) { const res = await fetch(LIA + path, { method, headers: { Authorization: `Bearer ${process.env.LIA_KEY}`, "Content-Type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body), }); const data = await res.json(); if (!res.ok) throw Object.assign(new Error(data.error), { status: res.status, data }); return data; } ``` ### Keys and modes A key only ever sees its own app, and only its own mode: the test key sees test sessions, charges, players and ads; the live key sees live ones. Something from the other mode is `not_found` (or `valid: false`). See [Test and live](/concepts/test-and-live). ### Errors Errors answer with a 4xx status and a body with an `error` code, sometimes with more fields: ```json { "error": "pool_empty", "pool": 3.5 } ``` | Status | Code | When | | --- | --- | --- | | 401 | `bad_key` | No key, a key in the wrong shape, an unknown key, a live key while your app isn't live, or any key while it's paused. | Each endpoint lists its own. Every code is in [Error codes](/reference/errors). ## Sessions ### POST /session/verify Checks a session token from [`Lia.init()`](/reference/sdk-lifecycle#lia-init) or [`Lia.session()`](/reference/sdk-lifecycle#lia-session), and tells you whose it is. ```http POST /api/apps/v1/session/verify Authorization: Bearer lia_live_… Content-Type: application/json { "session": "lias_eyJhcHAiOi…" } ``` | Field | Type | | | --- | --- | --- | | `session` | string | The token from the page. | Answers `200` either way: ```json { "valid": true, "playerId": "p_3f9c0a6d1b2e4f5a6b7c", "mode": "live", "exp": 1761998400000 } ``` ```json { "valid": false } ``` A token is valid when Lia signed it, it's for your app and your key's mode, and it hasn't ended (15 minutes after it was made). ## Products ### GET /products What your app sells, at the prices in Lia's records. Only active products, cheapest first. ```json { "products": [ { "id": "extra-lives", "name": "3 extra lives", "price": 5, "active": true }, { "id": "gold-pack", "name": "Gold pack", "price": 25, "active": true } ] } ``` ## Charges A charge is one payment on Lia's pay sheet. See [Payments](/concepts/payments). The charge object: | Field | Type | | | --- | --- | --- | | `id` | string | Like `ch_9b1f…`. | | `playerId` | string | Who paid. | | `productId` | string | What they paid for. | | `amount` | number | What they paid, in $LIA. | | `status` | string | `paid`, `delivered` or `refunded`. | | `mode` | string | `test` or `live`. | | `createdAt` | number | When it was paid. | | `deliveredAt` | number or null | When you delivered it. | | `refundedAt` | number or null | When it was refunded. | ### GET /charges/:id Is this charge real, paid, and whose? Always check here before delivering: never take the page's or a webhook's word for it. ```json { "charge": { "id": "ch_9b1f4c2e7a0d5b8f3e6a1c4d", "playerId": "p_3f9c0a6d1b2e4f5a6b7c", "productId": "extra-lives", "amount": 5, "status": "paid", "mode": "live", "createdAt": 1761998400000, "deliveredAt": null, "refundedAt": null } } ``` | Status | Code | When | | --- | --- | --- | | 404 | `not_found` | No such charge for your app in your key's mode. | ### POST /charges/:id/deliver Marks a charge delivered, once you've given the person what they bought. Safe to repeat: delivering a delivered charge changes nothing and answers the same. No body. ```json { "charge": { "id": "ch_9b1f…", "status": "delivered", "deliveredAt": 1761998405000, "…": "…" } } ``` | Status | Code | When | | --- | --- | --- | | 404 | `not_found` | No such charge in your key's mode. | | 409 | `refunded` | It was refunded, so don't deliver it. The body has the `charge` too. | ### POST /charges/:id/refund Gives the $LIA back to the person, when you can't deliver what they bought. Works on a paid or a delivered charge. Safe to repeat: refunding a refunded charge answers with it and pays nothing more. No body. ```json { "charge": { "id": "ch_9b1f…", "status": "refunded", "refundedAt": 1761998410000, "…": "…" } } ``` The full price goes back to the person's balance, in live mode, out of Lia's part and your share. A `charge.refunded` webhook is sent. | Status | Code | When | | --- | --- | --- | | 404 | `not_found` | No such charge in your key's mode. | ## Rewards See [Reward pools](/concepts/reward-pools). ### POST /rewards Pays a player from your app's reward pool, once per key. ```http POST /api/apps/v1/rewards Authorization: Bearer lia_live_… Content-Type: application/json { "playerId": "p_3f9c0a6d1b2e4f5a6b7c", "amount": 25, "key": "tournament-2026-11-01:p_3f9c0a6d1b2e4f5a6b7c", "reason": "Weekly tournament, 1st place" } ``` | Field | Type | | | --- | --- | --- | | `playerId` | string | The player to pay. | | `amount` | number | In $LIA, more than 0 and at most 10,000, to 6 decimal places. | | `key` | string | Yours, 1 to 128 of letters, digits, `_`, `.`, `:` and `-`. A key pays once, ever. Without it, the `Idempotency-Key` header is used. | | `reason` | string | Optional, up to 80 characters (longer is cut). Shown in the player's activity under your app's name. | Paid: ```json { "status": "paid", "amount": 25, "pool": 975 } ``` The same key again pays nothing and answers with what the first one paid: ```json { "status": "duplicate", "amount": 25, "pool": 975 } ``` `pool` is what's left in your pool after it. In test mode the reward is pretend: the pool isn't used and the daily limit doesn't apply. | Status | Code | When | | --- | --- | --- | | 400 | `bad_amount` | `amount` isn't a number above 0 and up to 10,000. | | 400 | `bad_key_field` | `key` is missing or not in the right shape. | | 404 | `unknown_player` | No such player in your app. | | 409 | `pool_empty` | The pool doesn't hold `amount`. The body has `pool`, what's left. | | 429 | `daily_limit` | This would take the player past your app's daily limit per player (in $LIA, per UTC day). The body has `dailyCapPerPlayer`. | A `reward.paid` webhook is sent for every reward paid (not for duplicates). ### GET /pool What's left in your reward pool, and the most one player can get from it a day. ```json { "balance": 975, "dailyCapPerPlayer": 50, "mode": "live" } ``` `dailyCapPerPlayer` is `0` when there's no daily limit. `balance` is the live pool, also when you ask with your test key. ## Notifications See [Notifications](/concepts/notifications). ### POST /notifications Sends a notification to a player who allowed them. It shows under your app's name and opens your app with your `startParam`. ```http POST /api/apps/v1/notifications Authorization: Bearer lia_live_… Content-Type: application/json { "playerId": "p_3f9c0a6d1b2e4f5a6b7c", "text": "It's your move against @amara.", "startParam": "match-8k2d" } ``` | Field | Type | | | --- | --- | --- | | `playerId` | string | Who to notify. | | `text` | string | 1 to 180 characters. Spaces and new lines become single spaces. No links, phone numbers or words used in scams. | | `startParam` | string | Optional. 1 to 64 of letters, digits, `_` and `-`. Your page reads it from `Lia.startParam`. | Sent: ```json { "sent": true, "pushed": true } ``` `pushed` is `false` when it reached Lia's bell but not the phone (the person has Lia's pushes off). | Status | Code | When | | --- | --- | --- | | 400 | `bad_text` | Empty, too long, or not allowed. When it's what the text says, `problem` is `link`, `phone` or `scam`. | | 400 | `bad_start_param` | `startParam` isn't in the right shape. | | 403 | `notifications_off` | Your app may not send notifications (its daily number is 0). | | 403 | `not_allowed` | This player hasn't allowed your app's notifications, or stopped them. | | 404 | `unknown_player` | No such player, or not one your key's mode reaches (a test key only reaches your testers). | | 429 | `rate_limited` | A limit was reached. For your app's own limits the body has `retryAt`, when you may try again. | ## Ads See [Ads](/concepts/ads). ### POST /ads/verify Was this ad really watched, and by whom? Check before giving anything for a rewarded video. ```http POST /api/apps/v1/ads/verify Authorization: Bearer lia_live_… Content-Type: application/json { "adId": "ad_8e1c4b0f2a9d47c6b3e15a07" } ``` ```json { "adId": "ad_8e1c4b0f2a9d47c6b3e15a07", "playerId": "p_3f9c0a6d1b2e4f5a6b7c", "watched": true, "watchedAt": 1761998400000 } ``` | Field | Type | | | --- | --- | --- | | `adId` | string | The id from `Lia.showAd()`. | | `playerId` | string | The player it was shown to. Check it's the one asking. | | `watched` | boolean | Watched to the end (or, for an interstitial, shown). | | `watchedAt` | number or null | When. | Lia doesn't mark an ad as used when you verify it, so pay each `adId` once yourself (use `ad:` as the reward key). | Status | Code | When | | --- | --- | --- | | 404 | `not_found` | No such ad for your app in your key's mode. | ## Lobby Public tables anyone on Lia can find. Lia keeps only the listing: your server runs the table. See [Public tables](/concepts/lobby). The table object: | Field | Type | | | --- | --- | --- | | `id` | string | Your table's id. | | `game` | string | Your app's id. | | `host` | object | `{ name }`: the host's Lia username. | | `seats`, `taken` | number | Seats in all, and taken. | | `title` | string or null | Filtered like everything people read on Lia. | | `stake`, `pot` | number | In LIA, for show. | | `createdAt`, `expiresAt` | number | | ### POST /lobby/tables Lists a table that's waiting for players, or updates its listing (it keeps its place in the list). List it again whenever its seats change. ```json { "tableId": "t8k2d", "hostPlayerId": "p_3f9c0a6d1b2e4f5a6b7c", "seats": 2, "taken": 1, "title": "Quick game", "stake": 5, "expiresIn": 900 } ``` | Field | Type | | | --- | --- | --- | | `tableId` | string | Yours, 1 to 57 of letters, digits, `_` and `-` (short enough to fit the startParam `lobby-`). | | `hostPlayerId` | string | The player hosting it. | | `seats` | number | Optional. 2 to 10, 2 by default. | | `taken` | number | Optional. Seats filled, the host's included: 1 to `seats`, 1 by default. | | `title` | string | Optional, up to 40 characters. | | `stake`, `pot` | number | Optional. In LIA, 0 to 10,000, shown in the list only. Lia doesn't hold or move them: charge stakes with your own product. | | `expiresIn` | number | Optional. Seconds the listing lasts, 60 to 3600, 1800 by default. | ```json { "listed": true, "tableId": "t8k2d", "seats": 2, "taken": 1, "expiresAt": 1761999300000 } ``` When `taken` reaches `seats`, the table is taken out of the list instead, and the answer is `{ "listed": false }`. | Status | Code | When | | --- | --- | --- | | 400 | `bad_table_id`, `bad_seats`, `bad_taken`, `bad_title`, `bad_amount`, `bad_expires_in` | A field doesn't fit. | | 404 | `unknown_player` | No such host in your app. | | 429 | `too_many_tables` | The host already has 3 open tables in your app. The body has `max`. | ### GET /lobby/tables Your app's open tables in your key's mode, newest first, up to 200: every host's, with blocks left aside (that's for each player's own view). ```json { "tables": [{ "id": "t8k2d", "game": "your-app", "host": { "name": "amara" }, "seats": 2, "taken": 1, "title": "Quick game", "stake": 5, "pot": 0, "createdAt": 1761998400000, "expiresAt": 1761999300000 }] } ``` ### POST /lobby/tables/:id/sit Seats a player at a listed table, before your server lets them in. Lia refuses people the host blocked or who blocked the host. When the last seat is taken, the table leaves the list. ```json { "playerId": "p_8a1e0c4d2b7f9e3a5c6d" } ``` ```json { "seated": true, "taken": 2, "seats": 2, "full": true } ``` | Status | Code | When | | --- | --- | --- | | 404 | `unknown_player` | No such player in your app. | | 404 | `not_found` | The table isn't listed, has ended, or the host and the player blocked one another. | | 409 | `full` | Every seat is taken. | | 409 | `own_table` | The player is the host. | ### DELETE /lobby/tables/:id Takes a table out of the list, when its game starts or it closes. Safe to repeat. ```json { "removed": true } ``` `removed` is `false` when it wasn't listed. ## Retrying Send an `Idempotency-Key` header with every `POST`, and the same one again on each retry of that request. A retry then never does anything twice, and answers as the first request did. ```http POST /api/apps/v1/notifications Authorization: Bearer lia_live_… Content-Type: application/json Idempotency-Key: 6f1c2a9e-0b7d-4c35-9e1a-2d8f4b7c3a10 { "playerId": "p_3f9c0a6d1b2e4f5a6b7c", "text": "It's your move." } ``` - **Any key you like**, 1 to 128 of letters, digits, `_`, `-`, `.` and `:`. A UUID per operation is right. - **The first request runs.** If it succeeds (2xx), its answer is kept for 24 hours, and a retry with the same key and the same body gets that answer again, with the header `Idempotent-Replayed: true`. - **A refusal keeps nothing** (4xx or 5xx), so you can fix the cause and retry with the same key. - **The same key with a different body** is `422 idempotency_key_reused`. - **The same key while the first is still running** is `409 idempotency_key_in_use` with `Retry-After: 1`. Retry, and you get the first one's answer. - **Keys are your app's own**, and live and test keep theirs apart. The same key on another endpoint is another key. Without the header, most endpoints are still safe to retry: reads change nothing, delivering and refunding are safe to repeat, and rewards pay once per `key`. Notifications are the exception: without the header, a retry sends the notification again. # Webhooks Source: https://docs.lialabs.org/reference/webhooks ## Setting one up Give the Lia team an https address on your server, and keep the webhook signing secret (`whsec_…`) that came with your keys. Lia then sends a `POST` there when something happens in your app. A webhook is a **nudge**, not the truth: check with the API before acting on one (for a charge, [`GET /charges/:id`](/reference/server-api#get-charges-id)). And don't rely on webhooks alone; your page can also tell your server (see [Sell an item](/guides/sell-an-item)). ## What's sent ```http POST /lia/webhook Content-Type: application/json Lia-Signature: t=1761998400,v1=5f2c8a… { "type": "charge.paid", "app": "your-app", "at": 1761998400123, "data": { "id": "ch_9b1f4c2e7a0d5b8f3e6a1c4d", "playerId": "p_3f9c0a6d1b2e4f5a6b7c", "productId": "extra-lives", "amount": 5, "status": "paid", "mode": "live", "createdAt": 1761998400000, "deliveredAt": null, "refundedAt": null } } ``` | Field | Type | | | --- | --- | --- | | `type` | string | What happened (below). | | `app` | string | Your app id. | | `at` | number | When it was sent, in milliseconds since 1970. | | `data` | object | What it's about. | ## Types | Type | When | `data` | | --- | --- | --- | | `charge.paid` | A person paid on Lia's pay sheet, in test or live. | The [charge](/reference/server-api#charges), with `status: "paid"` | | `charge.refunded` | You refunded a charge. | The charge, with `status: "refunded"` | | `reward.paid` | You paid a reward from your pool, in test or live. | `{ key, playerId, amount, reason, mode }` | Check `data.mode` (or the charge's `mode`) so test events never grant anything real. More types may be added: ignore types you don't know and answer `200`. ## Checking the signature Every webhook has a `Lia-Signature` header, `t=,v1=`. The signature is HMAC SHA-256, in hex, of the seconds, a dot, and the raw body, keyed with your whole webhook secret (including `whsec_`): ```text v1 = hex(HMAC_SHA256(key = "whsec_…", message = t + "." + rawBody)) ``` Check it against the raw body exactly as it arrived, before parsing it, and refuse webhooks more than a few minutes old. ```js title="Node.js" import { createHmac, timingSafeEqual } from "node:crypto"; export function liaWebhook(rawBody, header, secret, toleranceSeconds = 300) { const parts = Object.fromEntries((header ?? "").split(",").map((part) => part.split("="))); const t = Number(parts.t); if (!t || !parts.v1) return null; if (Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return null; const expected = Buffer.from(createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex")); const given = Buffer.from(parts.v1); if (expected.length !== given.length || !timingSafeEqual(expected, given)) return null; return JSON.parse(rawBody); } ``` ```js title="Cloudflare Workers, Deno, Bun" export async function liaWebhook(request, secret, toleranceSeconds = 300) { const rawBody = await request.text(); const parts = Object.fromEntries((request.headers.get("Lia-Signature") ?? "").split(",").map((part) => part.split("="))); const t = Number(parts.t); if (!t || !parts.v1 || Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return null; const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["verify"]); const signature = new Uint8Array(parts.v1.match(/../g).map((byte) => parseInt(byte, 16))); const ok = await crypto.subtle.verify("HMAC", key, signature, new TextEncoder().encode(`${t}.${rawBody}`)); return ok ? JSON.parse(rawBody) : null; } ``` [Handle webhooks](/guides/webhooks) has a complete handler. ## Answering and retries - Answer with any `2xx` status, quickly. Do slow work after answering. - If your server doesn't answer `2xx` (or can't be reached), Lia tries once more straight away. After that, the webhook isn't sent again. - So webhooks can be missed, and can arrive twice. Make your handler safe to run twice (key it on the charge id or the reward key), and have another way to catch up, such as [`Lia.charges()`](/reference/sdk-payments-and-ads#lia-charges) when the player opens your app. ## Your secret Your webhook secret changes whenever your keys are made again. Keep it on your server, next to your key. # Error codes Source: https://docs.lialabs.org/reference/errors ## In your page SDK calls reject with an `Error` that has a `code`. Messages are in English for you, not for players: show your own words. ### Anywhere | Code | Meaning | What to do | | --- | --- | --- | | `not_in_lia` | The page isn't open inside the Lia app. | Show "Open this in Lia". | | `not_ready` | Lia hasn't finished opening your app, or `Lia.inviteLink` was called before `Lia.init()`. | Call `Lia.init()` first, then try again. | | `timeout` | Lia didn't answer within 20 seconds. | Try again. | | `slow_down` | More than 20 calls in one second. | Call less often (don't call the SDK every frame). | | `busy` | Another of Lia's screens (pay sheet, ad, dialog, picker, scanner, Face ID) is open. | Wait for the first to finish. | | `unknown_method` | Lia doesn't know this call. An older Lia app, or a typo. | Check `Lia.isVersionAtLeast`. | | `unsupported` | This phone can't (no sensor, no Face ID, clipboard unavailable). | Offer another way. | | `failed` | Something went wrong in Lia. | Try again. | | `network` | No connection. | Try again. | | `not_open` | Lia has no player for this person in your app yet. | Call `Lia.init()` first. | | `not_found` | Lia couldn't find your app for this person (paused, or no longer open to them). | Close the app. | ### Payments, ads and invites | Code | From | Meaning | | --- | --- | --- | | `cancelled` | `Lia.pay` | The person closed the pay sheet without paying. | | `no_product` | `Lia.pay` | No active product with that id. | | `insufficient_balance` | `Lia.pay` | Not enough $LIA. Lia's sheet shows this itself and stays open, so your page usually hears `cancelled` instead. | | `bad_format` | `Lia.showAd` | Not `rewarded`, `rewardedInterstitial` or `interstitial`. | | `notifications_off` | `Lia.requestNotifications` | Your app may not send notifications. | | `bad_start_param` | `Lia.inviteFriend`, `Lia.inviteLink` | Not 1 to 64 of letters, digits, `_` and `-`. | | `bad_stake`, `bad_cursor` | `Lia.lobby.tables` | A stake that isn't a number of LIA, or a cursor that isn't the `next` from before. | | `bad_reason`, `bad_note`, `bad_tableId` | `Lia.lobby.report` | A reason other than name, unfair, abuse or other; a note over 300 characters. | | `bad_session` | `Lia.lobby` | Lia couldn't renew the session. Try again. | | `slow_down` | `Lia.lobby.report` | More than 20 reports in a day. | | `bad_idempotency_key` | `Lia.pay`, `Lia.inviteFriend`, `Lia.submitScore`, cloud storage writes | Not 1 to 64 of letters, digits, `_`, `-`, `.` and `:`. | Ad results that aren't a reward (`cancelled`, `skipped`, `no_ad`, `too_soon`) are answers, not errors: see [Ads](/concepts/ads#what-you-get-back). ### Storage, scores, the phone | Code | From | Meaning | | --- | --- | --- | | `storage_full` | Storage `setItem` | Cloud: 1024 keys. Device: 5 MB. Secure: 10 items. | | `bad_key`, `bad_keys`, `bad_value`, `bad_item` | Storage | A key or value that doesn't fit. | | `bad_score` | `Lia.submitScore` | Not a whole number from 0 to 1,000,000,000,000. | | `bad_refresh_rate` | Sensors' `start` | Not 20 to 1000 ms. | | `unknown_screen` | `Lia.openLiaLink` | Not one of Lia's screens. | | `bad_url` | `Lia.openLink` | Not an https address up to 2048 characters. | ### Arguments The SDK checks arguments before anything reaches Lia, and rejects (or throws) with `bad_` and the argument's name: `bad_productId`, `bad_text`, `bad_title`, `bad_body`, `bad_confirm`, `bad_message`, `bad_buttons`, `bad_button_type`, `bad_id`, `bad_position`, `bad_color`, `bad_text_color`, `bad_screen`, `bad_reason`, `bad_listener`. Each method's page says which it can give. ## From the server API Errors answer with a 4xx status and `{ "error": "" }`, sometimes with more fields. | Status | Code | From | Meaning | | --- | --- | --- | --- | | 401 | `bad_key` | Every endpoint | No key, an unknown key, a live key while your app isn't live, or your app is paused. | | 400 | `bad_amount` | `POST /rewards` | Not a number above 0 and up to 10,000. | | 400 | `bad_key_field` | `POST /rewards` | The reward's `key` (or, without one, the `Idempotency-Key` header) is missing or not 1 to 128 of letters, digits, `_`, `.`, `:` and `-`. | | 400 | `bad_idempotency_key` | Any `POST` | The `Idempotency-Key` header isn't 1 to 128 of letters, digits, `_`, `-`, `.` and `:`. | | 409 | `idempotency_key_in_use` | Any `POST` | A request with this key is still running. Wait a second (`Retry-After: 1`) and send it again. | | 422 | `idempotency_key_reused` | Any `POST` | This key was used with a different body. Use a new key for a new request. | | 400 | `bad_text` | `POST /notifications` | Empty, over 180 characters, or has a `problem`: `link`, `phone` or `scam`. | | 400 | `bad_start_param` | `POST /notifications` | Not 1 to 64 of letters, digits, `_` and `-`. | | 403 | `notifications_off` | `POST /notifications` | Your app may not send notifications. | | 403 | `not_allowed` | `POST /notifications` | The player hasn't allowed your notifications. | | 404 | `not_found` | Charges, `POST /ads/verify` | Nothing with that id in your key's mode. | | 404 | `unknown_player` | `POST /rewards`, `POST /notifications` | No such player (in your key's mode). | | 409 | `refunded` | `POST /charges/:id/deliver` | The charge was refunded. Don't deliver it. | | 409 | `pool_empty` | `POST /rewards` | Your pool doesn't hold the amount. Has `pool`. | | 429 | `daily_limit` | `POST /rewards` | The player's daily limit from your pool. Has `dailyCapPerPlayer`. | | 429 | `rate_limited` | `POST /notifications` | A notification limit. May have `retryAt`. | | 400 | `bad_table_id`, `bad_seats`, `bad_taken`, `bad_title`, `bad_amount`, `bad_expires_in` | `POST /lobby/tables` | A field doesn't fit. | | 429 | `too_many_tables` | `POST /lobby/tables` | The host has 3 open tables. Has `max`. | | 409 | `full`, `own_table` | `POST /lobby/tables/:id/sit` | Every seat is taken, or the player is the host. | `POST /session/verify` never errors for a bad token: it answers `{ "valid": false }`. # Changelog Source: https://docs.lialabs.org/changelog The SDK at `https://api.lialabs.org/sdk/lia-apps-v1.js` is always the newest version: Lia serves it so fixes reach every app. Check `Lia.version`, or `Lia.isVersionAtLeast()`, before using something added later. The Lia app on a person's phone may be older than the SDK; a call it doesn't know fails with `unknown_method`. ## Docs: 1 November 2026 The first Lia developer docs: getting started, concepts, guides, the full SDK and server reference, and search. ## SDK 1.6 - **Public tables.** Your server lists tables waiting for players (`POST /lobby/tables`) and seats each one through Lia (`/sit`); players find them with `Lia.lobby.tables()` or in Lia's own lobby, and can `Lia.lobby.report()` a player. See [Public tables](/concepts/lobby). ## Packages: 1 November 2026 The SDK as npm packages: [`@lia-labs/sdk`](/packages/sdk), typed and tree-shakeable, with bindings for [React](/packages/react), [Preact](/packages/preact), [Vue](/packages/vue), [Svelte](/packages/svelte), [Solid](/packages/solid) and [Angular](/packages/angular), [web components](/packages/elements), the low level [bridge](/packages/bridge), and [`mockLia()`](/packages/local-development) for building outside Lia. The script tag is unchanged. ## SDK 1.5 - **Idempotency keys.** `Lia.pay`, `Lia.inviteFriend`, `Lia.submitScore` and cloud storage's writes take an optional `{ idempotencyKey }`: the same key again doesn't do it twice and answers as the first call did. See [Idempotency keys](/reference/sdk-payments-and-ads#idempotency-keys). - **The server API** takes an `Idempotency-Key` header on every `POST`. Retries answer as the first request did, with `Idempotent-Replayed: true`. A reward's `key` can now be up to 128 characters, and the header stands in for it when there's none. See [Retrying](/reference/server-api#retrying). ## SDK 1.4 - **Sound.** `Lia.soundOn` says whether the person wants your app's sounds (they mute it from Lia's ⋯ menu), and the `"soundChanged"` event says when it changes. ## SDK 1.3 - **Invites.** `Lia.inviteFriend()` opens Lia's friend picker; friends get a card in their Lia chat that opens your app with your startParam. `Lia.inviteLink()` is a link anyone can open. ## SDK 1.2 - **Notifications.** `Lia.requestNotifications()`, `Lia.notificationsAllowed` and the `"notificationsChanged"` event; your server sends them with `POST /notifications`, each opening your app with a startParam (`Lia.startParam`, and the `"startParam"` event while open). ## SDK 1.1 What Telegram's Mini Apps have that fits Lia: - Lia's theme as `Lia.theme` and CSS variables, the viewport and safe areas. - Fullscreen, the sheet and its swipes, `Lia.expand()`, closing confirmation. - The bottom buttons, and the header's back and settings buttons. - Popups, alerts and confirms; Lia's toasts. - Every full screen ad format, paced by Lia. - The QR scanner, the clipboard, links in Lia's in-app browser or outside it, and Lia's own screens. - Cloud, device and secure storage; Face ID and Touch ID; the motion sensors. - `Lia.language` and `Lia.rtl` for your own translations. ## SDK 1.0 The first SDK: players signed in with `Lia.init()`, sessions for your server, Lia's pay sheet with `Lia.pay()`, rewarded videos, leaderboards, haptics, `Lia.confirm()` and sharing. The server API with charges, refunds, rewards from a funded pool, and ad checks.