Skip to main content

Server integration

Verifying webhooks

Signatures, replay windows and secret rotation.

What Finn signs

Every webhook delivery has an HMAC-SHA256 signature in the X-Finn-Signature header. Verify it before you trust anything in the body. Without verification, your endpoint accepts a payload from anyone who learns its URL, including fake call.completed events that write bad results into your CRM.

The header has two parts, separated by a comma:

PartMeaning
tUnix timestamp, in seconds, when the attempt was signed
v1HMAC-SHA256(secret, t + "." + raw_body), as lowercase hex
X-Finn-Signature: t=1758193449,v1=5f8a2c...

The signed string is the timestamp, a period, then the raw request body. If you parse the JSON and serialize it again, the key order and whitespace change and the signature won't match. Read the raw bytes first.

The HMAC key is the whole signing secret exactly as the dashboard showed it, including the whsec_ prefix, as UTF-8 text. Don't remove the prefix and don't decode the secret as hex or base64.

Finn signs every attempt separately. A retry has a new t and a new v1, even though the body is identical.

Verifying

Each webhook endpoint has its own signing secret, shown once when you add the endpoint in Settings → Integrations → Webhooks. Keep it in your secrets manager, not in code. It isn't related to your API keys (see authentication).

import crypto from "crypto";

export function verifyFinnSignature(
  rawBody: string,
  header: string,
  secret: string,
  toleranceSeconds = 300,
): boolean {
  const parts = Object.fromEntries(
    header.split(",").map((p) => p.split("=") as [string, string]),
  );
  const ts = parseInt(parts.t, 10);
  const sig = parts.v1;
  if (!ts || !sig) return false;

  if (Math.abs(Date.now() / 1000 - ts) > toleranceSeconds) return false;

  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${ts}.${rawBody}`)
    .digest("hex");

  const a = Buffer.from(sig);
  const b = Buffer.from(expected);
  if (a.length !== b.length) return false;
  return crypto.timingSafeEqual(a, b);
}
import hmac, hashlib, time

def verify_finn_signature(raw_body: bytes, header: str, secret: str, tolerance: int = 300) -> bool:
    parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
    try:
        ts = int(parts.get("t", "0"))
    except ValueError:
        return False
    sig = parts.get("v1", "")
    if not ts or not sig:
        return False
    if abs(time.time() - ts) > tolerance:
        return False
    expected = hmac.new(
        secret.encode(),
        f"{ts}.".encode() + raw_body,
        hashlib.sha256,
    ).hexdigest()
    return hmac.compare_digest(expected, sig)

Both versions get two details right. They compare with a constant-time function (timingSafeEqual, compare_digest), so response timing doesn't reveal the expected signature. And the Node version checks the lengths before calling timingSafeEqual, which throws an error instead of returning false when the lengths differ.

Connect it to a handler that receives the raw body:

import express from "express";

const app = express();

app.post(
  "/finn/post-call",
  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 raw = req.body.toString("utf8");
    if (!verifyFinnSignature(raw, header, process.env.FINN_WEBHOOK_SECRET!)) {
      return res.status(401).send("bad signature");
    }

    const event = JSON.parse(raw);
    if (event.event !== "call.completed") {
      return res.status(200).send("ignored");
    }

    await queue.push(event);
    res.status(200).send("ok");
  },
);

app.listen(3000);

Check event before you treat the body as a call. The Initialize test request (post_call.test or inbound_call.test) is signed the same way and passes verification, but it isn't a call and has no data. Return 200 for it so the URL is marked verified, and don't process it. See Test deliveries.

This handler doesn't remove duplicates. Before you act on events, add the dedupe step from webhook-retries.

Return 401 for a bad signature. Finn doesn't retry 4xx, so a forged request gets no second try. If your secret is misconfigured, though, every real delivery fails permanently, so watch the endpoint's Deliveries list after a deploy.

The replay window

The t value limits how long a captured delivery can be reused. The examples use a 300-second tolerance. Reject anything older.

The tolerance balances two risks. Too short, and real deliveries fail when your server's clock drifts, because the check compares Finn's clock with yours. Too long, and someone who captured a valid request can resend it for the whole window. Run NTP on the receiving server before you shorten the tolerance.

Each retry is signed with a fresh timestamp, and the whole retry sequence takes under a minute, so a 300-second window never rejects a real retry.

Checking the timestamp doesn't remove duplicates. Dedupe on event plus data.call_uuid with a unique constraint. See webhook-retries.

Rotating the secret

The signing secret is shown once, when you add the endpoint. The dashboard has no way to show it again or to rotate it in place. To rotate, replace the endpoint:

  1. Add a second endpoint with the same URL. Save its new secret.
  2. Change your handler to accept a signature that's valid under either secret. Try the new one first, then the old one.
  3. Check that the new endpoint's Deliveries list shows successful deliveries.
  4. Delete the old endpoint.
  5. Remove the old secret from your handler and your secrets manager.

During step 2, each call is delivered twice, once for each endpoint. Deduping on call_uuid handles that. Without it, every call during the rotation is processed twice. An organization can have up to 5 endpoints, so you need a free slot for step 1.

Other webhook URLs

A Finn's Data webhook URL (in Deployment settings) receives call.completed signed exactly as described on this page, but with that Finn's own signing secret rather than an endpoint secret. The Initialize test request for both the Data and Inbound webhook URLs uses the same t=<unix>,v1=<hex> format, so one verification function covers all of them. The Inbound webhook URL doesn't receive live events yet. See webhook-inbound.

What goes wrong

SymptomCause
Every signature failsThe body was parsed as JSON before verification. Verify the raw bytes.
Every signature fails with the raw bodyYou removed whsec_ from the secret or decoded it. Use the whole string.
Signatures fail now and thenThe receiving server's clock is drifting outside the tolerance window.
timingSafeEqual throwsThe header was cut off or malformed, so the lengths differ. Check the length first.
Verification passes but records are duplicatedYou aren't deduping on call_uuid. Retries and rotation both deliver valid duplicates.
Deliveries stop after a deployThe new deploy has the wrong secret. Your 401 responses are final, so those events won't be retried.

To add and delete endpoints, see api-webhooks. For the payload, see webhook-events.