Skip to main content

Server integration

Verifying webhooks

Signatures, replay windows and rotation.

What Finn signs

Every webhook delivery carries an HMAC-SHA256 signature in the X-Finn-Signature header. Verify it before you trust anything in the body. An unverified endpoint accepts any payload from anyone who learns your URL, including forged call.completed events that write bad outcomes into your CRM.

The header has two comma-separated parts.

PartMeaning
tUnix timestamp (seconds) at signing time
v1HMAC-SHA256(secret, t + "." + raw_body), hex encoded
X-Finn-Signature: t=1716391823,v1=abc123...

The signed string is the timestamp, a literal period, then the raw request body. Recomputing it over the parsed and re-serialized JSON will fail, because JSON re-serialization changes key order and whitespace. Read the raw bytes first.

Verifying

The signing secret is issued with the subscription. Store it in your secret manager, not in code. See authentication for how Finn handles API credentials generally.

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(","))
    ts = int(parts.get("t", 0))
    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)

Two details matter in both versions. Compare with a constant-time function (timingSafeEqual, compare_digest) so byte-by-byte timing does not leak the expected digest. And guard the length before timingSafeEqual, which throws on mismatched buffer lengths in Node rather than returning false.

Wire it into the handler with a raw body parser:

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"] as string | undefined;
    if (!header) 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);
    await queue.push(event);
    res.status(200).send("ok");
  },
);

app.listen(3000);

The replay window

The t value bounds how long a captured delivery stays usable. A 300 second tolerance is the value used in the examples above. Reject anything outside it.

The window trades two failures against each other. Too tight and legitimate deliveries fail when your server clock drifts, because the check compares Finn's clock to yours. Too loose and an attacker who captured one valid request can resend it for as long as the window lasts. Run NTP on the receiving host before narrowing the tolerance.

Timestamp checking is not deduplication. Finn retries failed deliveries, so the same event.id can arrive several times inside a valid window with a valid signature. Dedupe on event.id with a unique constraint or a seen-set. See webhook-retries for retry counts and timing.

Rotating the secret

Finn shows the signing secret once, at creation. The dashboard does not display it again, and there is no "reveal" action. If you lose it, you cannot recover it.

Rotation is done by replacing the subscription, not by editing a secret in place:

  1. Create a second subscription for the same events pointing at the same URL. Record the new secret.
  2. Change your handler to accept a signature valid under either the old or the new secret. Try the new one first, fall back to the old.
  3. Confirm both secrets are seeing traffic in Settings → Integrations → Webhooks → Logs in the dashboard.
  4. Delete the old subscription.
  5. Remove the old secret from your handler and your secret manager.

During step 2 you will receive each event twice, once per subscription. Your event.id dedupe handles that. Without it, rotation duplicates every record it touches.

In-place rotation with a Finn-managed overlap window, and a second active v1 key per subscription, are not yet settled. Assume the create-and-retire flow above.

What goes wrong

SymptomCause
Every signature failsBody was parsed as JSON before verification. Verify the raw bytes.
Signatures fail intermittentlyReceiving host clock drift against the tolerance window.
timingSafeEqual throwsHeader was truncated or malformed, so buffer lengths differ. Check length first.
Verification passes, records duplicateNo dedupe on event.id. Retries and rotation overlap both replay valid events.
Deliveries stop after a deploySecret was rotated by deleting the old subscription before the new one was live.

For signature testing against a local endpoint, see local-development. For the subscription create and delete calls, see api-webhooks. For the event catalog and payload shapes, see webhook-events.