Server reference
Webhooks
Copy page
Lia tells your server when a charge is paid or refunded and when a reward is paid, signed with your webhook secret. What's sent, how to check the signature, and how retries work.
#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). And don't rely on webhooks alone; your page can also tell your server (see Sell an item).
#What's sent
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, 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=<seconds>,v1=<signature>. 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_):
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.
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);
}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 has a complete handler.
#Answering and retries
- Answer with any
2xxstatus, 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()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.