# Webhooks

> 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. From the Lia developer docs: 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=<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_`):

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