Guides
Handle webhooks
Copy page
A complete webhook handler. Check the signature on the raw body, check with the API, act once, and answer quickly.
Lia sends charge.paid, charge.refunded and reward.paid to your webhook address, signed with your webhook secret. See 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.
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
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/:idsays 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
2xxquickly. 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): 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.