Server reference
Server API
Copy page
The API your app's own server calls with its secret key. Verify sessions, list products, confirm, deliver and refund charges, pay rewards from your pool, send notifications, check ads and list public tables.
#Basics
| Base address | https://api.lialabs.org/api/apps/v1 |
| Authentication | Authorization: Bearer <your key>, 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.
curl https://api.lialabs.org/api/apps/v1/pool \
-H "Authorization: Bearer $LIA_KEY"A small helper used in the examples below:
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.
#Errors
Errors answer with a 4xx status and a body with an error code, sometimes with more fields:
{ "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.
#Sessions
#POST/session/verify
Checks a session token from Lia.init() or Lia.session(), and tells you whose it is.
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:
{ "valid": true, "playerId": "p_3f9c0a6d1b2e4f5a6b7c", "mode": "live", "exp": 1761998400000 }{ "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.
{
"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.
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.
{
"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.
{ "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.
{ "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.
#POST/rewards
Pays a player from your app's reward pool, once per key.
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:
{ "status": "paid", "amount": 25, "pool": 975 }The same key again pays nothing and answers with what the first one paid:
{ "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.
{ "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.
#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.
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:
{ "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.
#POST/ads/verify
Was this ad really watched, and by whom? Check before giving anything for a rewarded video.
POST /api/apps/v1/ads/verify
Authorization: Bearer lia_live_…
Content-Type: application/json
{ "adId": "ad_8e1c4b0f2a9d47c6b3e15a07" }{ "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:<adId> 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.
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.
{ "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-<id>). |
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. |
{ "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).
{ "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.
{ "playerId": "p_8a1e0c4d2b7f9e3a5c6d" }{ "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.
{ "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.
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_usewithRetry-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.