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 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 webhooksInbound routing webhook
DirectionFinn tells you something happenedFinn asks you what to do
TimingAfter the fact, best-effortOn the hot path, caller is on the line
Your response bodyIgnored, only the status code mattersRead and acted on
DeadlineReturn 2xx within 5 secondsRespond in under 500ms, timeout at 1000ms
Configured onA webhook subscriptionA 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"
  }
}
FieldTypeNotes
idstringUnique per event. This is your dedupe key.
typestringEvent name. Switch on this.
created_atISO-8601When the event was generated, not when it was delivered.
org_idstringYour organization. Useful if one endpoint serves several orgs.
dataobjectEvent-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