Skip to main content

Server integration

Delivery and retries

Timeouts, backoff and idempotency on your side.

What Finn expects from your endpoint

Finn treats a delivery as successful only when your endpoint returns a 2xx status within 5 seconds. Anything else counts as a failure: a 5xx response, a connection timeout, a TLS error, a redirect you do not follow to a 2xx.

ConditionFinn's behavior
2xx within 5 secondsDelivery marked delivered. No retry.
Response takes longer than 5 secondsTreated as a failure. Retried.
5xx responseTreated as a failure. Retried.
Signature rejected by you (401)Not retried by design. Fix your secret and replay from the dashboard.

The 5 second budget covers the whole request, not just your handler body. If you parse the payload, write to your CRM, call Slack, and insert into a warehouse before responding, you will exceed it under load and Finn will retry a call you already processed.

Retry schedule

Finn makes 5 attempts over roughly 10 minutes on 5xx or timeout. After the final attempt the delivery is marked failed and stops.

The exact interval between attempts is not published, so do not build logic that assumes a specific gap. Assume attempts can arrive close together and that a retry can land while your handler is still processing the first attempt.

The last attempt is recorded in the dashboard webhook log at Settings → Integrations → Webhooks → Logs, along with a Replay button for manual redelivery. Replay is a dashboard action. There is no replay endpoint.

Ack first, work later

The correct shape is: verify the signature, record the event, enqueue, return 200. Everything expensive happens after the response.

import express from "express";
import { Queue } from "bullmq";

const app = express();
const queue = new Queue("finn-events", {
  connection: { host: "127.0.0.1", port: 6379 },
});

app.post(
  "/finn/post-call",
  express.raw({ type: "application/json" }),
  async (req, res) => {
    const raw = req.body.toString();
    const sig = req.headers["x-finn-signature"] as string;

    if (!verifyFinnSignature(raw, sig, process.env.FINN_WEBHOOK_SECRET!)) {
      return res.status(401).send("bad signature");
    }

    const event = JSON.parse(raw);

    // Claim the event id. Unique index on event_id makes this atomic.
    try {
      await db.query(
        "INSERT INTO finn_webhook_events (event_id, type, received_at) VALUES ($1, $2, now())",
        [event.id, event.type],
      );
    } catch (err: any) {
      if (err.code === "23505") {
        // unique_violation — already claimed by an earlier attempt
        return res.status(200).send("duplicate");
      }
      // Real database failure. Return 5xx so Finn retries.
      return res.status(500).send("storage error");
    }

    await queue.add(event.type, event, { jobId: event.id });
    res.status(200).send("ok");
  },
);

Two details matter here. The insert is the dedupe claim, so it has to happen before the work, not after. And the 500 branch is deliberate: if your own storage is down you want the retry, so returning 200 to be polite loses the event permanently.

Idempotency on your side

Dedupe on event.id. It is stable across retries of the same event. call_id is not a safe dedupe key on its own because several events for one call (call.started, call.completed, call.transferred) share it.

CREATE TABLE finn_webhook_events (
  event_id    text PRIMARY KEY,
  type        text NOT NULL,
  received_at timestamptz NOT NULL DEFAULT now(),
  processed_at timestamptz
);

Make the downstream write idempotent too, not only the ingest. A retry that slips past your seen-set should still not create a second CRM row.

def handle_call_completed(data):
    # Upsert keyed on call_id, not an insert.
    db.execute(
        """
        INSERT INTO calls (call_id, outcome, sentiment, duration_seconds, recording_url)
        VALUES (%(call_id)s, %(outcome)s, %(sentiment)s, %(duration)s, %(recording)s)
        ON CONFLICT (call_id) DO UPDATE SET
          outcome          = EXCLUDED.outcome,
          sentiment        = EXCLUDED.sentiment,
          duration_seconds = EXCLUDED.duration_seconds,
          recording_url    = EXCLUDED.recording_url
        """,
        {
            "call_id": data["call_id"],
            "outcome": data["outcome"]["label"],
            "sentiment": data["sentiment"],
            "duration": data["duration_seconds"],
            "recording": data["recording_url"],
        },
    )

Ordering

Delivery order is best-effort. call.completed can arrive before call.started for the same call. Sort by created_at inside your consumer rather than trusting arrival order, and write handlers that tolerate an out-of-order arrival instead of erroring on a missing parent row.

If a late call.started would overwrite fields already set by call.completed, guard the update with a created_at comparison so an older event cannot clobber a newer one.

Signature verification and replay tolerance

The signature includes a timestamp and the verification examples use a 300 second tolerance window. That interacts with retries: the timestamp is from the original event, so a retry arriving 10 minutes later can fall outside a 300 second window and fail verification. If you keep a strict tolerance, expect late retries to be rejected and to need a dashboard replay. Widen the tolerance if you would rather accept them. See webhook security for the full verification code.

What goes wrong

SymptomCause
Duplicate rows downstreamDeduping after the work instead of before, or deduping on call_id.
Events silently lostHandler catches its own errors and returns 200. Finn sees success and never retries.
Retry storms during an incidentHandler is slow, not failing. Every delivery exceeds 5 seconds and every one is retried.
Late retries fail signature checkTimestamp tolerance narrower than the retry window.
Recording URL 403 after the factRecording URLs expire after 30 days by default. Mirror the file to your own storage during processing.
Nothing arrives at allEndpoint not reachable from the public internet. Use a tunnel for local development.