Webhooks
Webhooks tell your server what happened in a channel without any client being involved. A participant's session ends, a gift is sent — we POST a signed JSON body to a URL you register, and retry if your server does not answer.
Register an endpoint#
Endpoints belong to one project. Create them in the console, or with the dashboard API, which authenticates with your dashboard session rather than an API key.
POST /v1/apps/{appId}/webhooks
| Field | Type | Rules |
|---|---|---|
url | string | Required. Must start with https://. Plain HTTP is rejected with 400. |
events | string[] | Required, at least one. Every entry must be a supported event name, or the whole request is rejected with 400. |
curl -X POST https://api.applooma.dev/v1/apps/app_123/webhooks \-H "Authorization: Bearer <dashboard token>" \-H "Content-Type: application/json" \-d '{"url": "https://yourapp.com/hooks/applooma","events": ["session.ended", "gift.sent"]}'# → 201# {# "id": "wh_…",# "url": "https://yourapp.com/hooks/applooma",# "events": ["session.ended", "gift.sent"],# "secret": "whsec_…" ← shown once, never returned again# }
The signing secret is shown once
It is returned only in the response that creates the endpoint. Listing endpoints never includes it. Store it in your server's environment before you close the dialog; if you lose it, delete the endpoint and create a new one.
GET /v1/apps/{appId}/webhooks lists your endpoints — id, url, events, active flag and creation time, without the secret — together with a supportedEvents array. DELETE /v1/apps/{appId}/webhooks/{endpointId} removes one and returns { "deleted": true }.
You can register several endpoints for the same project. Each one receives only the events it subscribed to, and each has its own secret.
The delivery#
Every delivery is a POST with content-type: application/json, a user-agent of AppLooma-Webhooks/1.0, and this envelope:
{"id": "evt_9f3c1a0b8d7e6f5a4b3c2d1e","type": "session.ended","createdAt": "2026-01-01T20:15:00.000Z","data": { … }}
| Field | Meaning |
|---|---|
id | Unique id for this event. Use it for idempotency. |
type | The event name, matching what you subscribed to. |
createdAt | ISO 8601 timestamp of when the event was generated. |
data | The event-specific payload, described below. |
Verifying the signature#
Each delivery carries an x-applooma-signature header:
x-applooma-signature: t=1767298500,v1=8f0c1d2e3a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5
tis the Unix timestamp, in seconds, at which the delivery attempt was signed.v1is a hex-encoded HMAC-SHA256 over the string"{t}.{raw body}", keyed with your endpoint'swhsec_secret.
Sign the raw body, not the parsed one
The HMAC covers the exact bytes we sent. If your framework parses JSON before your handler runs and you re-serialise it, key order and whitespace can change and the signature will never match. Read the raw body first, verify, then parse.
import express from 'express';import { createHmac, timingSafeEqual } from 'crypto';const app = express();const SECRET = process.env.APPLOOMA_WEBHOOK_SECRET; // whsec_…const TOLERANCE_SEC = 300; // 5 minutesfunction verify(rawBody, header) {if (typeof header !== 'string') return false;// header: "t=<unix>,v1=<hex>"const parts = Object.fromEntries(header.split(',').map((kv) => kv.split('=').map((s) => s.trim())));const t = Number(parts.t);const received = parts.v1;if (!Number.isFinite(t) || typeof received !== 'string') return false;// Reject old signatures so a captured delivery cannot be replayed.if (Math.abs(Math.floor(Date.now() / 1000) - t) > TOLERANCE_SEC) return false;const expected = createHmac('sha256', SECRET).update(`${t}.${rawBody}`).digest('hex');const a = Buffer.from(expected, 'utf8');const b = Buffer.from(received, 'utf8');return a.length === b.length && timingSafeEqual(a, b);}// express.raw() so req.body is the exact bytes we sent.app.post('/hooks/applooma',express.raw({ type: 'application/json' }),(req, res) => {const raw = req.body.toString('utf8');if (!verify(raw, req.headers['x-applooma-signature'])) {return res.status(400).send('bad signature');}const event = JSON.parse(raw);// Answer immediately, then do the work.res.status(200).send('ok');void handleEvent(event).catch((err) => console.error(err));});app.listen(3001);
Reject anything that fails verification
Your endpoint is a public URL. Without signature verification, anyone who finds it can post fake gift and session events at your business logic. Compare with a constant-time function, and never fall back to “process it anyway” when the header is missing.
Events#
session.ended
Sent when a participant's session in a channel finishes — one event per participant, not one per channel. This is the event to bill, analyse or log against.
{"id": "evt_…","type": "session.ended","createdAt": "2026-01-01T20:15:00.000Z","data": {"roomName": "friday-show","identity": "user-42","joinedAt": "2026-01-01T19:44:12.000Z","leftAt": "2026-01-01T20:15:00.000Z","durationSec": 1848}}
roomNameis your channel name, without any internal prefix.identityis the identity your server put in that participant's token.durationSecis the whole-second difference between join and leave.- A participant who joins, leaves and rejoins produces one event per session.
gift.sent
Sent when POST /v1/gifts/send records a transaction. See virtual gifting.
{"id": "evt_…","type": "gift.sent","createdAt": "2026-01-01T20:15:00.000Z","data": {"txId": "tx_9f3c…","roomName": "friday-show","giftId": "gft_rocket","giftName": "Rocket","sender": "user-42","receiver": "user-7","quantity": 3,"coinTotal": 1500}}
txId is the same id the send call returned and the same one clients see in the realtime event, so it is the natural key for reconciling all three.
Coins are your unit
coinTotal is a number you defined in your catalogue. AppLooma does not move money — see virtual gifting.
Respond 2xx, and respond fast#
Each delivery attempt is abandoned after 10 seconds. Your handler should verify the signature, put the event on a queue or in a table, and return a 2xx immediately. Do the real work afterwards.
Slow handlers look like failures
If you charge a card, send an email or update five tables before responding, a slow dependency turns into a timeout — and a timeout turns into a retry, so you do all of that work again. Acknowledge first, process second.
Retries and backoff#
A delivery is written to a durable queue before the first attempt, so nothing is lost if our servers restart. It is attempted up to six times over about eleven hours: after a failure we wait 1 minute, then 5 minutes, 30 minutes, 2 hours and 8 hours.
| Your response | What we do |
|---|---|
| 2xx | Delivered. No further attempts. |
| 4xx | Treated as a permanent problem with the request. No retry. |
| 408 or 429 | Treated like a 5xx and retried. |
| 5xx | Retried on the schedule above, six attempts in total. |
| Timeout or network error | Retried on the schedule above. Each attempt is given 10 seconds. |
- Every attempt of the same delivery carries the same
idand the same body. - The timestamp
tand the signature are computed fresh for each attempt, so a retry hours later still passes a strict timestamp check. - After the last failed attempt the delivery is marked failed, not dropped. The console's Webhooks page lists every delivery from the last 30 days with its status, the last HTTP code or error, and the exact payload, and a Resend button puts it back on the queue. The same is available over REST at
GET /v1/apps/:appId/webhooks/deliveriesandPOST …/deliveries/:id/replaywith a console session. - Deliveries are dispatched asynchronously and are not ordered. Two events about the same channel may arrive in either order — use
createdAtand your own state rather than assuming sequence.
Returning 4xx stops the retries
If your handler crashes and your framework answers with a 400, we will not try again. Make sure unexpected errors surface as 5xx, and reserve 4xx for deliveries you have decided to reject on purpose, such as a bad signature.
Idempotency#
Because failed attempts are retried, your handler must tolerate seeing the same event twice. Use the envelope's id: it is unique per event and identical across retries.
Store event ids with a unique constraint
CREATE TABLE applooma_events (id TEXT PRIMARY KEY, -- the envelope's "id"type TEXT NOT NULL,received_at TIMESTAMPTZ NOT NULL DEFAULT now());
Insert first, and stop if it is already there
async function handleEvent(event) {// A unique violation here means we have already processed this event.const inserted = await db.query('INSERT INTO applooma_events (id, type) VALUES ($1, $2) ON CONFLICT DO NOTHING',[event.id, event.type]);if (inserted.rowCount === 0) return; // duplicate — do nothingswitch (event.type) {case 'session.ended': return onSessionEnded(event.data);case 'gift.sent': return onGiftSent(event.data);default: return; // unknown type — ignore it}}
Make the work itself idempotent where you can
Keying on txId for gifts, or on (roomName, identity, joinedAt) for sessions, gives you a second layer of protection that survives a crash between the insert and the work.
Ignore event types you do not handle
Return 2xx for an event type your code does not know. New types may be added, and a handler that throws on an unfamiliar type turns a harmless delivery into a retry storm.
Testing locally#
- The URL must be
httpsand reachable from the internet. Use a tunnelling tool to expose your development server. - Register a second endpoint pointing at your tunnel and subscribe it to the same events; endpoints are independent, so this does not disturb production.
- To generate a
session.ended, join a channel from a browser tab and close it. To generate agift.sent, callPOST /v1/gifts/send. - Log the raw body and the signature header while you are getting verification working, then remove that logging — the body contains your users' identities.