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

FieldTypeRules
urlstringRequired. Must start with https://. Plain HTTP is rejected with 400.
eventsstring[]Required, at least one. Every entry must be a supported event name, or the whole request is rejected with 400.
Create
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:

Request body
{
"id": "evt_9f3c1a0b8d7e6f5a4b3c2d1e",
"type": "session.ended",
"createdAt": "2026-01-01T20:15:00.000Z",
"data": { … }
}
FieldMeaning
idUnique id for this event. Use it for idempotency.
typeThe event name, matching what you subscribed to.
createdAtISO 8601 timestamp of when the event was generated.
dataThe event-specific payload, described below.

Verifying the signature#

Each delivery carries an x-applooma-signature header:

x-applooma-signature: t=1767298500,v1=8f0c1d2e3a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5
  • t is the Unix timestamp, in seconds, at which the delivery attempt was signed.
  • v1 is a hex-encoded HMAC-SHA256 over the string "{t}.{raw body}", keyed with your endpoint's whsec_ 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.

Express
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 minutes
function 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.

Body
{
"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
}
}
  • roomName is your channel name, without any internal prefix.
  • identity is the identity your server put in that participant's token.
  • durationSec is 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.

Body
{
"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 responseWhat we do
2xxDelivered. No further attempts.
4xxTreated as a permanent problem with the request. No retry.
408 or 429Treated like a 5xx and retried.
5xxRetried on the schedule above, six attempts in total.
Timeout or network errorRetried on the schedule above. Each attempt is given 10 seconds.
  • Every attempt of the same delivery carries the same id and the same body.
  • The timestamp t and 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/deliveries and POST …/deliveries/:id/replay with a console session.
  • Deliveries are dispatched asynchronously and are not ordered. Two events about the same channel may arrive in either order — use createdAt and 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.

1

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()
);
2

Insert first, and stop if it is already there

Node.js
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 nothing
switch (event.type) {
case 'session.ended': return onSessionEnded(event.data);
case 'gift.sent': return onGiftSent(event.data);
default: return; // unknown type — ignore it
}
}
3

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 https and 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 a gift.sent, call POST /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.

Next#