Skip to main content

Server integration

Webhooks overview

Receiving call events on your own server.

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.

PropertyValue
Eventscall.completed
SentOnce per call after it ends, usually within about 5 minutes. See When it's sent.
Your responseReturn 2xx within 10 seconds. The body is ignored, but keep it under 64 KB.
AttemptsUp to 3: one try and two retries, 1 second and 2 seconds apart
SignatureX-Finn-Signature: t=<unix>,v1=<hex>, using a secret for each endpoint
Managed inSettings → 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.

CallWhen call.completed is sent
Not answered: no answer, busy, failed, voicemail, canceledUsually within about 5 minutes of the call ending.
Answered, on a Finn with no post-call analysis fieldsUsually within about 5 minutes of the call ending.
Answered, on a Finn with post-call analysis fieldsAs 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": []
  }
}
FieldTypeNotes
eventstringThe event name. Check it before you read data.
created_atISO 8601When Finn built the payload, not when it was delivered.
dataobjectDetails 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