LiaDevelopers

Server reference

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 addresshttps://api.lialabs.org/api/apps/v1
AuthenticationAuthorization: Bearer <your key>, your test key (lia_test_…) or live key (lia_live_…)
BodiesJSON, with Content-Type: application/json
AmountsIn $LIA, as numbers, to 6 decimal places
TimesMilliseconds since 1970 (UTC)

Call it from your server only. Your key is a secret: never put it in your page.

Terminal
curl https://api.lialabs.org/api/apps/v1/pool \
  -H "Authorization: Bearer $LIA_KEY"

A small helper used in the examples below:

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.

#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 }
StatusCodeWhen
401bad_keyNo 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.

HTTP
POST /api/apps/v1/session/verify
Authorization: Bearer lia_live_…
Content-Type: application/json

{ "session": "lias_eyJhcHAiOi…" }
FieldType
sessionstringThe 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.

The charge object:

FieldType
idstringLike ch_9b1f….
playerIdstringWho paid.
productIdstringWhat they paid for.
amountnumberWhat they paid, in $LIA.
statusstringpaid, delivered or refunded.
modestringtest or live.
createdAtnumberWhen it was paid.
deliveredAtnumber or nullWhen you delivered it.
refundedAtnumber or nullWhen 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
  }
}
StatusCodeWhen
404not_foundNo 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, "…": "…" } }
StatusCodeWhen
404not_foundNo such charge in your key's mode.
409refundedIt 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.

StatusCodeWhen
404not_foundNo 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.

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"
}
FieldType
playerIdstringThe player to pay.
amountnumberIn $LIA, more than 0 and at most 10,000, to 6 decimal places.
keystringYours, 1 to 128 of letters, digits, _, ., : and -. A key pays once, ever. Without it, the Idempotency-Key header is used.
reasonstringOptional, 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.

StatusCodeWhen
400bad_amountamount isn't a number above 0 and up to 10,000.
400bad_key_fieldkey is missing or not in the right shape.
404unknown_playerNo such player in your app.
409pool_emptyThe pool doesn't hold amount. The body has pool, what's left.
429daily_limitThis 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.

#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" }
FieldType
playerIdstringWho to notify.
textstring1 to 180 characters. Spaces and new lines become single spaces. No links, phone numbers or words used in scams.
startParamstringOptional. 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).

StatusCodeWhen
400bad_textEmpty, too long, or not allowed. When it's what the text says, problem is link, phone or scam.
400bad_start_paramstartParam isn't in the right shape.
403notifications_offYour app may not send notifications (its daily number is 0).
403not_allowedThis player hasn't allowed your app's notifications, or stopped them.
404unknown_playerNo such player, or not one your key's mode reaches (a test key only reaches your testers).
429rate_limitedA 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.

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 }
FieldType
adIdstringThe id from Lia.showAd().
playerIdstringThe player it was shown to. Check it's the one asking.
watchedbooleanWatched to the end (or, for an interstitial, shown).
watchedAtnumber or nullWhen.

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).

StatusCodeWhen
404not_foundNo 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:

FieldType
idstringYour table's id.
gamestringYour app's id.
hostobject{ name }: the host's Lia username.
seats, takennumberSeats in all, and taken.
titlestring or nullFiltered like everything people read on Lia.
stake, potnumberIn LIA, for show.
createdAt, expiresAtnumber

#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 }
FieldType
tableIdstringYours, 1 to 57 of letters, digits, _ and - (short enough to fit the startParam lobby-<id>).
hostPlayerIdstringThe player hosting it.
seatsnumberOptional. 2 to 10, 2 by default.
takennumberOptional. Seats filled, the host's included: 1 to seats, 1 by default.
titlestringOptional, up to 40 characters.
stake, potnumberOptional. In LIA, 0 to 10,000, shown in the list only. Lia doesn't hold or move them: charge stakes with your own product.
expiresInnumberOptional. 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 }.

StatusCodeWhen
400bad_table_id, bad_seats, bad_taken, bad_title, bad_amount, bad_expires_inA field doesn't fit.
404unknown_playerNo such host in your app.
429too_many_tablesThe 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 }
StatusCodeWhen
404unknown_playerNo such player in your app.
404not_foundThe table isn't listed, has ended, or the host and the player blocked one another.
409fullEvery seat is taken.
409own_tableThe 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.

Type to search every page, method and endpoint.