Receive webhooks
This page is the receiver. The event table, collection filter, payload data variants, SSRF checks, and the admin route table are on Webhooks.
Create the webhook
Section titled “Create the webhook”-
Open Database → Automation → Webhooks → Add webhook.
-
URL:
https://your-app.example/api/webhooks/tefily. Production requires HTTPS. Loopback and private IPs are rejected. -
Signing secret (optional): set one. The console shows the secret once after create. Later responses only have
hasSecretandsecretLastFour. -
Events: include at least Record created (
record.created). An empty event list means all events. The event names are on Webhooks. -
Collections:
posts, or leave the list empty for every collection. Auth events ignore this filter. -
Leave the webhook active.
Optional:
POST /api/projects/YOUR_PROJECT_ID/webhooksAuthorization: Bearer kfx_…Content-Type: application/json
{ "url": "https://your-app.example/api/webhooks/tefily", "secret": "YOUR_SIGNING_SECRET", "events": ["record.created"], "collections": ["posts"], "isActive": true}Create returns 201 and includes secret only on that response.
Store the secret in server env TEFILY_WEBHOOK_SECRET. Do not use a service token as the HMAC secret.
Read the body before parsing
Section titled “Read the body before parsing”The signature is HMAC-SHA256 over the raw UTF-8 body. Hex digest, lowercase. The header value is sha256=<hex>. The header is omitted when the webhook has no secret. Every delivery also sends:
| Header | Value |
|---|---|
Content-Type |
application/json |
X-Kallax-Event |
Event name, for example record.created |
X-Kallax-Delivery |
Delivery id |
X-Kallax-Signature |
sha256=<hex> when a secret is set |
Envelope (the same bytes that were signed):
{ "id": "<same value as X-Kallax-Delivery>", "event": "record.created", "projectId": "…", "collection": "posts", "timestamp": "2026-09-15T18:00:00.000Z", "actor": { "type": "project", "userId": "…" }, "data": { "record": { } }}actor.type is project, platform, service, or anonymous. For record.created, data.record is the document after insert. Other data shapes are on Webhooks.
id is the delivery row id. Retries reuse that id and the same body.
Verify, then dedupe
Section titled “Verify, then dedupe”Next.js App Router. Call request.text() first. Do not call request.json() before the HMAC.
import { createHmac, timingSafeEqual } from 'node:crypto';import { NextResponse } from 'next/server';
function verifySignature(secret: string, rawBody: string, header: string | null): boolean { if (!header?.startsWith('sha256=')) return false; const expected = createHmac('sha256', secret).update(rawBody).digest('hex'); const actual = header.slice('sha256='.length); const a = Buffer.from(expected); const b = Buffer.from(actual); if (a.length !== b.length) return false; return timingSafeEqual(a, b);}
export async function POST(request: Request) { const rawBody = await request.text(); const ok = verifySignature( process.env.TEFILY_WEBHOOK_SECRET!, rawBody, request.headers.get('x-kallax-signature'), ); if (!ok) return NextResponse.json({ error: 'Bad signature' }, { status: 401 });
const payload = JSON.parse(rawBody) as { id: string; event: string; collection?: string; data: { record?: { _id?: string } }; }; const deliveryId = request.headers.get('x-kallax-delivery') ?? payload.id;
if (await alreadyHandled(deliveryId)) { return new NextResponse(null, { status: 204 }); } await markHandled(deliveryId);
if (payload.event === 'record.created' && payload.collection === 'posts') { // payload.data.record } return new NextResponse(null, { status: 204 });}alreadyHandled and markHandled are your store, for example a database unique key on the delivery id. Tefily has no dedupe API.
Return 2xx for a duplicate. A non-2xx is a failure and will be retried.
Retries
Section titled “Retries”- HTTP 200–299 marks the delivery
success. Anything else, including a network error, stayspendinguntil attempts are exhausted. - At most 5 attempts (
kallaxWebhookMaxAttempts). - After a failure, the next try is scheduled
min(3600, 2^attempt)seconds later.attemptis 1 on the first failure, so 2s, then 4s, 8s, 16s. A worker runs about once a minute, so a short delay still waits for that pass. - Each try uses a 5 second connect timeout, a 5 second timeout opening the POST, and a 5 second timeout reading the response.
- The same delivery id and the same body are sent again. The original Data API call does not fail when delivery fails.
- Deliveries are listed in the console on that webhook, and at
GET /api/projects/:projectId/webhooks/:id/deliveries.
The console can send a test (webhook.ping).