What webhooks are for
A webhook is an HTTP POST that Finn sends to a URL you control. Today Finn sends one event, call.completed. It arrives once for each of your organization's calls after the call has ended, answered or not, with the post-call analysis when there is one. Your server receives the event, checks the signature, stores the event and responds.
Webhooks only tell you what happened. Finn looks only at your response's status code, not its body, though a body over 64 KB counts as a failed attempt. Finn doesn't call your server during a live call to ask what to do. See webhook-inbound.
| Property | Value |
|---|---|
| Events | call.completed |
| Sent | Once per call after it ends, usually within about 5 minutes. See When it's sent. |
| Your response | Return 2xx within 10 seconds. The body is ignored, but keep it under 64 KB. |
| Attempts | Up to 3: one try and two retries, 1 second and 2 seconds apart |
| Signature | X-Finn-Signature: t=<unix>,v1=<hex>, using a secret for each endpoint |
| Managed in | Settings → Integrations → Webhooks in the dashboard |
Add an endpoint
In the dashboard, open Settings → Integrations → Webhooks and click Add endpoint. Paste a public https:// URL and save. Finn shows the signing secret (whsec_...) once. Copy it then, because you can't view it later.
You can't create endpoints with an API key, and there's no /api/v1 route for it. An organization can have up to 5 endpoints. api-webhooks covers the URL rules, the delivery log and deleting endpoints.
A Finn's Deployment settings also has a Data webhook URL. It receives the same call.completed event for that Finn's calls only, signed with that Finn's own secret in the same X-Finn-Signature format. Use it when one Finn's results go to a different system; use the endpoints under Settings → Integrations → Webhooks for everything else. If a call matches both, each URL gets its own delivery. Initialize next to the field sends one test request to the URL; see Test deliveries. The Inbound webhook URL field next to it is not live yet.
When it's sent
Every call that ends produces one call.completed for each endpoint, and one for the Finn's Data webhook URL if it has one. That includes calls from outbound and inbound deployments, calls placed with POST /api/v1/calls, and calls nobody answered.
| Call | When call.completed is sent |
|---|---|
| Not answered: no answer, busy, failed, voicemail, canceled | Usually within about 5 minutes of the call ending. |
| Answered, on a Finn with no post-call analysis fields | Usually within about 5 minutes of the call ending. |
| Answered, on a Finn with post-call analysis fields | As soon as the analysis is saved, usually within about 5 minutes. If no analysis has arrived after about an hour, the event is sent anyway with whatever analysis exists, which can be an empty analysis array. |
Finn checks for finished calls every 5 minutes and looks back 3 hours. A new endpoint isn't backfilled with older calls: a call whose record last changed before you added the endpoint isn't sent to it. Setting a Finn's Data webhook URL can deliver that Finn's calls from up to 3 hours earlier.
Payload
Each call.completed delivery has the same top-level shape:
{
"event": "call.completed",
"created_at": "2026-09-18T11:14:09.000Z",
"data": {
"call_uuid": "3f2504e0-4f89-11d3-9a0c-0305e82c3301",
"finn_id": "8f14e45f-ceea-467a-9f6a-1c0e5b2a77d1",
"deployment_id": "b2d4a611-99c7-42f0-8a3e-1f5d6c0e9a72",
"status": "completed",
"duration_seconds": 97,
"analysis": []
}
}
| Field | Type | Notes |
|---|---|---|
event | string | The event name. Check it before you read data. |
created_at | ISO 8601 | When Finn built the payload, not when it was delivered. |
data | object | Details for the event. webhook-post-call lists every field. |
The payload has no event ID and no organization ID. To remove duplicates, use event plus data.call_uuid as the key. If one receiver serves several organizations, give each organization its own URL or secret so you can tell their deliveries apart.
The same event name also arrives in the X-Finn-Event header, so you can route a delivery before you parse it.
Test deliveries
call.completed is the only real event. Clicking Initialize next to a Finn's Data webhook URL or Inbound webhook URL in Deployment settings also sends one test request to that URL, with a different body:
{
"event": "post_call.test",
"finn_id": "8f14e45f-ceea-467a-9f6a-1c0e5b2a77d1",
"org_id": "3c1a9f52-0d77-4a6e-9b21-8e4c5f7d2a10",
"test": true,
"sent_at": "2026-09-18T11:02:44.000Z"
}
event is post_call.test for the Data webhook URL and inbound_call.test for the Inbound webhook URL. The body has no created_at or data, the request has no X-Finn-Event header, and its User-Agent is Finn-Webhook/1.0 rather than Finn-Webhooks/1. It's signed the same way, with that Finn's secret. Verify it, return 2xx so the URL is marked verified, and don't process it as a call.
A minimal receiver
Do two things before any business logic: verify the signature against the raw body, then record the call so you don't process it twice.
import express from "express";
import crypto from "crypto";
const app = express();
const SECRET = process.env.FINN_WEBHOOK_SECRET!;
app.post(
"/finn/events",
express.raw({ type: "application/json" }),
async (req, res) => {
const header = req.headers["x-finn-signature"];
if (typeof header !== "string") 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("utf8")}`)
.digest("hex");
const a = Buffer.from(parts.v1);
const b = Buffer.from(expected);
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.status(401).send("bad signature");
}
const event = JSON.parse(req.body.toString("utf8"));
if (event.event !== "call.completed") return res.status(200).send("ignored");
const dedupeKey = `${event.event}:${event.data.call_uuid}`;
try {
if (await db.eventExists(dedupeKey)) return res.status(200).send("dup");
await queue.push(event);
await db.markEventSeen(dedupeKey);
} catch {
return res.status(500).send("try again");
}
res.status(200).send("ok");
},
);
app.listen(3000);
The event is marked seen only after it's queued. If you mark it first and the queue push fails, Finn's retry looks like a duplicate and the call is never processed. A failure returns 500, so Finn retries.
The handler responds and returns. It doesn't call your CRM, write to your warehouse or wait on Slack. Those calls fail or hang at the worst moments, and a handler that takes longer than 10 seconds counts as a failed delivery.
What goes wrong
You parse the body before you verify it. express.json() and FastAPI's automatic body parsing consume the raw bytes. The HMAC then runs over re-serialized JSON, which never matches. Every request fails, and it looks like a wrong secret.
You do the work before responding. A slow third-party API pushes your handler past 10 seconds. Finn retries, and the retry can arrive while the first attempt is still running.
You return 4xx for events you don't handle yet. Finn treats 4xx (except 429) as final and doesn't retry. Respond 2xx and discard events you don't need.
You rely on webhooks alone. If all 3 attempts fail, for example because your server was down, Finn never sends that event again. Reconcile with GET /api/v1/calls/{call_uuid}.
Failures
The 100 most recent deliveries for each endpoint are listed under Deliveries in Settings → Integrations → Webhooks, with their status, number of attempts and your server's response code. You can't replay a delivery from the dashboard or the API. webhook-retries explains the retry schedule and how to recover missed events.
Next
- webhook-events: the event catalog
- webhook-post-call: the
call.completedpayload, field by field - webhook-security: verifying signatures in Node and Python
- webhook-retries: retry schedule and recovering missed events