What webhooks are for
A webhook is an HTTP POST from Finn to a URL you control. Finn sends one every time something happens on a call: the call started, the call ended, a transfer fired, a campaign finished. Your server receives the payload, verifies it, and does whatever you need with it.
There are two different mechanisms on this platform, and they are not interchangeable.
| Outbound event webhooks | Inbound routing webhook | |
|---|---|---|
| Direction | Finn tells you something happened | Finn asks you what to do |
| Timing | After the fact, best-effort | On the hot path, caller is on the line |
| Your response body | Ignored, only the status code matters | Read and acted on |
| Deadline | Return 2xx within 5 seconds | Respond in under 500ms, timeout at 1000ms |
| Configured on | A webhook subscription | A phone number |
This page covers the event webhooks. The routing handshake is on inbound-routing.
Register a subscription
In the dashboard: Settings → Integrations → Webhooks → New webhook. Pick your events, paste your HTTPS endpoint URL, save. Finn shows the signing secret once at that moment. Copy it then. You cannot view it again afterwards.
Via the API:
curl https://api.hirefinn.ai/v1/webhooks \
-H "Authorization: Bearer $FINN_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://your-app.com/finn/events",
"events": ["call.completed"],
"description": "CRM + analytics sync"
}'
The response includes secret. Store it where your server can read it at request time.
Envelope
Every event shares the same outer shape. Only data differs by event type.
{
"id": "evt_2H4abc",
"type": "call.completed",
"created_at": "2026-05-22T14:32:14.892Z",
"org_id": "org_8fac17c5",
"data": {
"call_id": "cal_xyz789",
"deployment_id": "dep_abc123",
"finn_id": "fn_def456"
}
}
| Field | Type | Notes |
|---|---|---|
id | string | Unique per event. This is your dedupe key. |
type | string | Event name. Switch on this. |
created_at | ISO-8601 | When the event was generated, not when it was delivered. |
org_id | string | Your organization. Useful if one endpoint serves several orgs. |
data | object | Event-specific payload. See webhook-events. |
Finn signs every delivery with HMAC-SHA256 in the X-Finn-Signature header. Verify it before you trust anything in the body, and verify against the raw bytes of the request, not re-serialized JSON. Full implementation in webhook-security.
A minimal receiver
The two things that must happen before any business logic: verify the signature, and check whether you have already seen this id.
import express from "express";
import crypto from "crypto";
const app = express();
const SECRET = process.env.FINN_WEBHOOK_SECRET!;
app.post(
"/finn/events",
// raw body — express.json() would break signature verification
express.raw({ type: "application/json" }),
async (req, res) => {
const header = req.headers["x-finn-signature"] as string | undefined;
if (!header) return res.status(401).send("missing signature");
const parts = Object.fromEntries(
header.split(",").map((p) => p.split("=") as [string, string]),
);
const ts = parseInt(parts.t, 10);
if (!ts || !parts.v1) return res.status(401).send("malformed signature");
if (Math.abs(Date.now() / 1000 - ts) > 300) {
return res.status(401).send("stale timestamp");
}
const expected = crypto
.createHmac("sha256", SECRET)
.update(`${ts}.${req.body.toString()}`)
.digest("hex");
const ok = crypto.timingSafeEqual(
Buffer.from(parts.v1),
Buffer.from(expected),
);
if (!ok) return res.status(401).send("bad signature");
const event = JSON.parse(req.body.toString());
if (await db.eventExists(event.id)) return res.status(200).send("dup");
await db.markEventSeen(event.id);
// Ack first, work later. Anything slow goes on the queue.
await queue.push(event);
res.status(200).send("ok");
},
);
app.listen(3000);
Note the shape of that handler. It acknowledges and returns. It does not call your CRM, insert into your warehouse, or wait on Slack. Those calls fail and hang at the worst possible times, and a hung handler becomes a redelivery.
What will go wrong
You parse before you verify. express.json() or FastAPI's automatic body parsing consumes the raw bytes. The HMAC then runs over re-serialized JSON with different whitespace and never matches. The signature will fail on every single request and look like a wrong secret.
You do the work inline. Finn treats anything slower than 5 seconds as a failure and retries. If your handler writes to a CRM synchronously, a slow third-party API turns into duplicate CRM records, because the retry arrives while your first attempt is still running.
You dedupe on the wrong key. Retries carry the same id. Dedupe on id, and back it with a unique constraint in your database rather than an in-process set, which does not survive a restart or a second replica.
You assume ordering. Delivery is best-effort ordered. call.completed can land before call.started. Order by created_at in your own store, or write handlers that do not care.
Failures and replays
Failed deliveries retry on 5xx and timeouts. Everything that was attempted is visible in Settings → Integrations → Webhooks → Logs in the dashboard, with the response your endpoint returned and a Replay button for individual events. Retry counts and backoff timing are in webhook-retries.
A 4xx from your endpoint is treated as your decision, not a transient fault. Return 401 for a bad signature, but do not return 400 for a payload field you have not implemented yet. Ack it and drop it, otherwise you burn the retry budget on events you never intended to process.
Next
- webhook-events for the event catalog and per-event
datapayloads - webhook-security for signature verification in full, in Node and Python
- webhook-retries for retry schedule and replay behavior
- local-development for receiving events on localhost
- inbound-routing for the real-time routing handshake