What Finn expects from your endpoint
A delivery succeeds when your endpoint returns any 2xx status within 10 seconds. Finn doesn't read the response body, but it stops at 64 KB: a longer body counts as a failure. Return a short body such as ok.
| Your endpoint | What Finn does |
|---|---|
Returns 2xx within 10 seconds | Marks the delivery delivered. No retry. |
| Takes longer than 10 seconds | Counts it as a failure and retries. |
| Connection or TLS error | Counts it as a failure and retries. |
Returns 5xx or 429 | Counts it as a failure and retries. |
Returns 3xx | Counts it as a failure and retries. Finn doesn't follow redirects. |
| Returns a response body larger than 64 KB, with any status | Counts it as a failure and retries. |
Returns any other 4xx (for example 400, 401, 404) | Marks the delivery failed. No retry. |
The URL is no longer https, doesn't resolve, or resolves to a private address | Marks the delivery failed. No retry. |
The 10 seconds covers the whole request. If you parse the payload, write to your CRM, call Slack and insert into a warehouse before you respond, you'll go over the limit under load.
Retry schedule
| Attempt | When |
|---|---|
| 1 | As soon as the event is ready |
| 2 | 1 second after attempt 1 fails |
| 3 | 2 seconds after attempt 2 fails |
After attempt 3, the delivery is marked failed and Finn stops. With every timeout included, the whole sequence takes less than a minute. A server that's down for longer than that misses the event.
Finn doesn't redeliver later. There's no replay button and no replay endpoint.
You can see each delivery's status, number of attempts and your server's response code under Deliveries for the endpoint in Settings → Integrations → Webhooks. The list shows the endpoint's 100 most recent deliveries.
Recovering missed events
Webhooks are the fast path. GET /api/v1/calls/{call_uuid} is how you catch up. It returns the same fields as the webhook, plus billing.
- Calls you placed with
POST /calls. You already have eachcall_uuid. Every so often, look up the calls that never got acall.completedand read them from the API. - Calls from deployments and inbound calls. The API has no endpoint for listing calls. The endpoint's Deliveries list shows when deliveries failed. Use Call History in the dashboard to find the calls from that period. Older failures drop off the list after 100 newer deliveries, so check it soon after an outage.
Respond first, work later
The right order is: verify the signature, skip anything that isn't call.completed, record the event, add it to a queue and respond 200. A worker does the slow work after you respond and marks the event processed.
import express from "express";
import { Queue, Worker } from "bullmq";
import { Pool } from "pg";
import { verifyFinnSignature } from "./verify-finn-signature"; // from webhook-security
const db = new Pool();
const connection = { host: "127.0.0.1", port: 6379 };
const queue = new Queue("finn-events", { connection });
const app = express();
app.post(
"/finn/post-call",
express.raw({ type: "application/json" }),
async (req, res) => {
const raw = req.body.toString("utf8");
const sig = req.headers["x-finn-signature"];
if (typeof sig !== "string" || !verifyFinnSignature(raw, sig, 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");
}
const callUuid: string = event.data.call_uuid;
try {
await db.query(
"INSERT INTO finn_webhook_events (event, call_uuid) VALUES ($1, $2) ON CONFLICT DO NOTHING",
[event.event, callUuid],
);
const { rows } = await db.query(
"SELECT processed_at FROM finn_webhook_events WHERE event = $1 AND call_uuid = $2",
[event.event, callUuid],
);
if (rows[0].processed_at) {
return res.status(200).send("duplicate");
}
await queue.add(event.event, event.data, {
jobId: callUuid,
attempts: 5,
backoff: { type: "exponential", delay: 10_000 },
});
} catch {
return res.status(500).send("storage error");
}
res.status(200).send("ok");
},
);
new Worker(
"finn-events",
async (job) => {
await handleCallCompleted(job.data);
await db.query(
"UPDATE finn_webhook_events SET processed_at = now() WHERE event = 'call.completed' AND call_uuid = $1",
[job.data.call_uuid],
);
},
{ connection },
);
app.listen(3000);
handleCallCompleted is your own code. Make it idempotent, as shown below.
A few details matter:
- The event check comes before
data. The test request that Initialize sends has nodata(see Test deliveries). Readingevent.data.call_uuidfirst throws, and the URL never verifies. Answer anything that isn'tcall.completedwith200and drop it. - A duplicate means processed, not just received. The row records that the event arrived;
processed_atrecords that the work finished. If a delivery was stored but the enqueue failed, Finn's retry queues it again instead of being dropped as a duplicate. - The job ID is the bare
call_uuid. BullMQ ignores a second job with an ID it already has, so a retry that arrives while the first job is still queued doesn't run twice. BullMQ rejects custom job IDs that contain:, so don't build one likecall.completed:<uuid>. - The
500branch is on purpose. If your database or queue is down, you want Finn to retry. If you return200to be polite, the event is lost.
Duplicates on your side
Each endpoint gets at most one call.completed per call. Finn records each delivery before sending it, so the same call is never delivered to an endpoint twice. You can still process an event twice yourself, though. For example, attempt 1 might reach your server, but your response arrives after the 10-second timeout, so Finn sends attempt 2. Or you might run two endpoints for the same organization.
The payload has no event ID. Remove duplicates on the pair (event, data.call_uuid):
CREATE TABLE finn_webhook_events (
event text NOT NULL,
call_uuid uuid NOT NULL,
received_at timestamptz NOT NULL DEFAULT now(),
processed_at timestamptz,
PRIMARY KEY (event, call_uuid)
);
Make the downstream write idempotent too, not only the ingest:
def handle_call_completed(data):
db.execute(
"""
INSERT INTO calls (call_uuid, status, sentiment, duration_seconds, successful)
VALUES (%(call_uuid)s, %(status)s, %(sentiment)s, %(duration)s, %(successful)s)
ON CONFLICT (call_uuid) DO UPDATE SET
status = EXCLUDED.status,
sentiment = EXCLUDED.sentiment,
duration_seconds = EXCLUDED.duration_seconds,
successful = EXCLUDED.successful
""",
{
"call_uuid": data["call_uuid"],
"status": data["status"],
"sentiment": data["user_sentiment"],
"duration": data["duration_seconds"],
"successful": data["call_successful"],
},
)
Ordering
Each call produces one event, so there's no ordering to worry about within a call. Across calls, events don't arrive in the order the calls ended. A call that wasn't answered can be sent before an earlier answered call that is still waiting for its analysis. Sort by data.updated_at or data.created_at if order matters to you.
Signatures and retries
Finn signs every attempt with a new timestamp. A retry carries a fresh t, so a 300-second tolerance window doesn't reject retries. See webhook security.
What goes wrong
| Symptom | Cause |
|---|---|
| Duplicate rows downstream | You check for duplicates after doing the work, or you don't key on call_uuid. |
| Events recorded but never processed | Your duplicate check only asks whether the row exists. If the enqueue failed after the insert, every retry looks like a duplicate. Check processed_at. |
| Initialize never marks the URL verified | Your handler reads data before checking event, and the test request has no data. |
| Events lost during an outage | Your server was unreachable for longer than the 3 attempts take. Reconcile from the API. |
| Events silently lost | Your handler catches its own errors and returns 200, so Finn sees success. |
| Events rejected forever | Your handler returns 400 or 404 for payloads it doesn't expect. Finn doesn't retry 4xx. |
| Nothing arrives at all | The endpoint can't be reached from the public internet. Use a tunnel for local development. |