What this webhook does
When a call arrives on one of your numbers, Finn can ask your server which agent should answer before the caller hears anything. Your server returns an agent ID, variables, and optional overrides. Finn then starts the voice agent with that configuration.
caller dials → Finn answers ring → Finn POSTs your URL → your server returns agent + vars → Finn streams the agent
The handshake adds roughly 100-200ms of latency. If a fixed agent per number is enough, you do not need this. Configure the number in inbound-routing instead.
Configure the endpoint
Set the URL in the dashboard under Settings → Phone Numbers → [number] → Inbound Webhook URL. Finn sends one {"ping": true} request on save to check reachability.
You can also set it through the API:
curl https://api.hirefinn.ai/v1/phone-numbers/ph_1234 \
-X PATCH \
-H "Authorization: Bearer $FINN_API_KEY" \
-H "Content-Type: application/json" \
-d '{ "inbound_webhook_url": "https://your-app.com/finn/inbound" }'
Set a fallback agent on the same number. Every failure path below routes to it, so a number without one has nothing to fall back to.
Request body
{
"session_id": "ws_2H4abc",
"request_type": "inbound",
"phone_number_id": "ph_1234",
"channel": "pstn",
"from": { "phone": "+919876543210", "country": "IN", "carrier": "Airtel" },
"to": { "phone": "+918765432100", "country": "IN" },
"received_at": "2026-05-22T14:30:00Z",
"metadata": {}
}
For WhatsApp calls, channel is whatsapp and the from block carries wa_id, display_name, and profile_pic_url instead of phone, country, and carrier. Code that reads from.phone unconditionally will crash on WhatsApp traffic. See whatsapp-calling.
Response body
{
"finn_id": "fn_def456",
"language": "hi-IN",
"variables": {
"customer_name": "Priya M",
"account_tier": "premium",
"last_order_id": "ord_99831"
},
"knowledge_base_ids": ["kb_general", "kb_premium_perks"],
"metadata": { "campaign_tag": "premium-support-q2", "ab_cohort": "B" },
"recording_enabled": true,
"max_call_duration_seconds": 600
}
| Field | Required | Notes |
|---|---|---|
finn_id | Yes | Which agent answers. Must belong to your org. |
language | No | Overrides the agent default. en-US, hi-IN, es-ES. |
variables | No | Key/value bag, readable in the prompt as {variable_name}. See agents-variables. |
knowledge_base_ids | No | Overrides which knowledge bases load for this call. |
metadata | No | Attached to the call record and the post-call webhook. |
recording_enabled | No | Overrides the agent default. |
max_call_duration_seconds | No | Hard cap. Default 1800. |
Reject or transfer instead
{ "action": "reject", "reason": "blocked_caller" }
Finn ends the call with the configured outgoing message. Use for DNC enforcement or after-hours hangups.
{ "action": "transfer", "to": "+918888888888", "reason": "vip_route" }
Routes straight to a human, skipping the agent. See tools-transfer.
Latency budget
The caller is hearing ring tone while your server thinks. This is the hot path.
| Metric | Value |
|---|---|
| Target response time | Under 500ms (p99) |
| Timeout | 1000ms |
| Timeout, 5xx, or invalid JSON | Falls back to the number's default agent |
Ways this goes wrong in production:
- CRM lookups with no index on phone number. Cache them for a few minutes.
- Webhook handler in one region, database in another. Two 80ms hops eat a third of the budget.
- Deciding routing live. Precompute the answer onto the customer record and read one row.
- Enrichment work that could wait. Move it to webhook-post-call.
Node handler
import express from "express";
const app = express();
app.use(express.json());
app.post("/finn/inbound", async (req, res) => {
const { from } = req.body;
const phone = from.phone ?? `+${from.wa_id}`;
const customer = await crm.findByPhone(phone);
if (customer?.dnc) {
return res.json({ action: "reject", reason: "dnc" });
}
if (customer?.tier === "vip") {
return res.json({
action: "transfer",
to: process.env.VIP_DESK_NUMBER!,
reason: "vip_route",
});
}
return res.json({
finn_id: customer?.tier === "premium" ? "fn_premium_aria" : "fn_standard_aria",
language: from.country === "IN" ? "hi-IN" : "en-US",
variables: {
customer_name: customer?.name ?? "there",
account_tier: customer?.tier ?? "standard",
last_order_id: customer?.last_order_id ?? "",
},
knowledge_base_ids:
customer?.tier === "premium" ? ["kb_general", "kb_premium"] : ["kb_general"],
metadata: { ab_cohort: customer?.ab_cohort ?? "A" },
});
});
app.listen(3000);
Verify the signature
Same scheme as post-call webhooks: HMAC-SHA256 over the raw request body, sent in the X-Finn-Signature header.
const sig = req.headers["x-finn-signature"] as string;
const ok = verifyFinnSignature(rawBody, sig, process.env.FINN_INBOUND_SECRET!);
if (!ok) return res.status(401).send("bad signature");
Note that rejecting an unsigned request with 401 puts the call on the fallback agent, which is the correct outcome. Full implementation in webhook-security.
Test without a real call
finn inbound simulate \
--phone-number-id ph_1234 \
--from +919876543210 \
--webhook-url https://your-app.com/finn/inbound
Output shows your endpoint response, measured latency, and the resolved agent and variables. Safe to run in CI. For tunneling to a local handler, see local-development.
Symptoms and causes
| Symptom | Cause |
|---|---|
| Every call gets the default agent | Your endpoint exceeded the 1000ms timeout. Check the webhook log for timings. |
| Caller hears 2-3s of silence before the agent speaks | Endpoint is slow but under the timeout. |
| Variables never appear in speech | Prompt placeholder and webhook key differ. Use snake_case on both sides. |
| Wrong agent answered | The finn_id belongs to another org. |
action: reject ignored | Response JSON was invalid, so Finn fell back. |
Retry behavior does not apply here. A failed inbound webhook is not retried because the call has already moved on. See webhook-retries for the webhooks that are.