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.
| Condition | Finn's behavior |
|---|---|
| 2xx within 5 seconds | Delivery marked delivered. No retry. |
| Response takes longer than 5 seconds | Treated as a failure. Retried. |
| 5xx response | Treated 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
| Symptom | Cause |
|---|---|
| Duplicate rows downstream | Deduping after the work instead of before, or deduping on call_id. |
| Events silently lost | Handler catches its own errors and returns 200. Finn sees success and never retries. |
| Retry storms during an incident | Handler is slow, not failing. Every delivery exceeds 5 seconds and every one is retried. |
| Late retries fail signature check | Timestamp tolerance narrower than the retry window. |
| Recording URL 403 after the fact | Recording URLs expire after 30 days by default. Mirror the file to your own storage during processing. |
| Nothing arrives at all | Endpoint not reachable from the public internet. Use a tunnel for local development. |