A webhook is an HTTP request Livqeno sends to your backend when something happens, like a new message or a stream ending. Instead of your backend asking Livqeno "anything new?" over and over, Livqeno tells you. In this guide you'll build the endpoint that receives them.
Where this fits: this is the last step of the flow. Your app joins calls and chats with tokens; webhooks tell your backend what happened there. Typical uses: save a message to your search index, send a push notification, or record when a webinar ended.
What you'll build
An endpoint that:
- Checks each request really came from Livqeno. (Your endpoint is a public URL, so anyone on the internet can send it a request.)
- Replies quickly. (Slow replies are treated as failures.)
- Handles each event once, even though Livqeno may send it more than once. (Retries after a timeout or network error can deliver the same event twice.)
What you need
- A project and a dashboard login. (Webhooks are registered with a dashboard session, not an API key.)
- A public URL. For local development, use a tunnel (see step 6).
Steps
1. Register an endpoint
Tell Livqeno where to send events. On your computer, with a dashboard
login token (LIVQENO_TOKEN, see CLI), register the
URL for one environment and the events you want. You can also do this from
the dashboard's Webhooks page:
curl -X POST "$LIVQENO_API_URL/v1/projects/$PROJECT_ID/webhooks" \
-H "Authorization: Bearer $LIVQENO_TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"url": "https://api.example.com/hooks/livqeno",
"environment": "PRODUCTION",
"events": ["message.created", "live_stream.ended"]
}'- The response includes a signing secret. You see it only once. Save it
as
LIVQENO_WEBHOOK_SECRETon your backend. Livqeno uses it to sign every delivery, and you use it to check the signature in Step 3. - Leave out
eventsto get every event, including new types added later. - Unknown fields are rejected with a 400. So a typo fails loudly instead of quietly getting nothing.
2. Keep the raw body
Most broken receivers fail here. You must check the signature against the exact bytes Livqeno sent. Parsing and re-stringifying the JSON can change key order and spaces, and then the signature won't match:
import express from 'express';
const app = express();
// Raw body for this route only; JSON parsing everywhere else.
app.post('/hooks/livqeno', express.raw({ type: 'application/json' }), handler);
app.use(express.json());3. Check the signature
Every delivery has a Swalvo-Signature header like t=1727780000,v1=5f2c…:
a timestamp (t) and a signature (v1). The signature is an HMAC: a code
made from the secret plus the timestamp and body. Only someone with your
secret can produce it. On your backend, recompute it and compare:
import { createHmac, timingSafeEqual } from 'node:crypto';
function verify(rawBody: string, header: string, secret: string): boolean {
const parts = Object.fromEntries(header.split(',').map((p) => p.trim().split('=')));
const timestamp = Number(parts.t);
// Reject anything older than five minutes. This is what stops a captured
// delivery being replayed later.
if (!Number.isFinite(timestamp) || Math.abs(Date.now() / 1000 - timestamp) > 300) {
return false;
}
const expected = createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex');
const a = Buffer.from(parts.v1 ?? '', 'utf8');
const b = Buffer.from(expected, 'utf8');
// Length check first: timingSafeEqual throws on a mismatch, and a plain
// === leaks the expected signature one byte at a time.
return a.length === b.length && timingSafeEqual(a, b);
}Three things to get right:
- Use the raw body, not re-stringified JSON.
- Check the timestamp. It's part of what's signed, so old requests (over 5 minutes) can't be replayed later.
- Compare in constant time with
timingSafeEqual. A plain===can leak the signature to an attacker through timing.
4. Reply first, then do the work
Livqeno waits at most 5 seconds for your reply. So check the signature, reply
200 straight away, and only then do the slow work (database writes, emails,
API calls):
async function handler(req: express.Request, res: express.Response) {
const raw = req.body.toString('utf8');
if (!verify(raw, req.header('swalvo-signature') ?? '', process.env.LIVQENO_WEBHOOK_SECRET!)) {
return res.sendStatus(400);
}
const eventId = req.header('livqeno-event-id')!;
const event = JSON.parse(raw);
res.sendStatus(200); // ack inside the 5s timeout
void handleEvent(eventId, event); // then do the real work
}5. Ignore duplicates
Livqeno retries any delivery that failed or timed out, so the same event can reach you twice. Your handler must be idempotent: handling an event a second time must change nothing. The simple way is to remember which event ids you've already handled:
async function handleEvent(eventId: string, event: LivqenoEvent) {
// Unique index on event_id. A second delivery of the same event is a no-op.
const inserted = await db.webhookEvents.insertIfAbsent({ eventId, type: event.type });
if (!inserted) return;
switch (event.type) {
case 'message.created':
await indexMessage(event.data.message);
break;
case 'live_stream.ended':
await finaliseStream(event.data);
break;
}
}Livqeno-Event-Id stays the same on every retry of an event. Put a unique
index on it in your database, and duplicates are skipped. That's all you need.
6. Local development
Livqeno's servers are on the internet, so they can't reach localhost on
your laptop. Some rules to know:
- In production, the URL must be
https://, and internal addresses are refused. - Outside production, loopback URLs are accepted when you register them, but Livqeno's hosted servers still can't reach your laptop through them.
- The easy route is a tunnel: a tool that gives your laptop a temporary
public
httpsURL and forwards requests to your local server. Pick your project, then register the tunnel's URL:
# Any tunnel that gives you a public https URL works.
livqeno projects use <project-id> # from `livqeno projects list`
# then register the tunnel URL with environment: "DEVELOPMENT"Register the tunnel in the development environment, so no production traffic reaches your laptop.
You should now see deliveries arrive. Trigger one (for example, send a chat
message in development) and check the dashboard's Webhooks page: the
attempt shows 200.
How it works
- Webhooks never slow down chat. A message is saved and delivered to users before its webhook is even queued. A broken endpoint can't delay or break a chat message.
- Retries wait longer each time: 10s, 20s, 40s, 80s, 160s, 320s. Then the
delivery is marked
FAILED. A non-2xx reply, a timeout, or a connection error all cause a retry. - A dead URL gets turned off. After 50 failures in a row, the endpoint is disabled automatically. Turning it back on in the dashboard resets the count.
- Order is not guaranteed. A retried event can arrive after a newer one.
Sort by
createdAtin the payload. Don't assume events come in order.
Production tips
- URL checks only look at the hostname. Livqeno blocks loopback and private IP addresses typed directly, but doesn't look up DNS. A hostname that points to a private address still passes. This is a known limitation.
- To change the secret, delete and re-create the endpoint. There's no rotate endpoint.
- Watch your deliveries. The dashboard's Webhooks page shows recent attempts and the last error. Otherwise, an endpoint that was turned off automatically means data quietly lost.
message.createdincludes the message text. You're handling user content. Use HTTPS, and think about what your logs keep.
Next steps
- Build for production: webhooks are one item on the pre-launch list.
- Webhooks: the full reference.
- Event catalogue: all 17 event types.
- Idempotency