Payload shape
Every delivery has the same top-level shape.
{
"event": "call.completed",
"created_at": "2026-09-18T11:14:09.000Z",
"data": { }
}
| Field | Type | Notes |
|---|---|---|
event | string | One of the events below. Check it before you read data. |
created_at | ISO 8601 | When Finn built the payload, not when it was delivered. |
data | object | Details for the event. |
Every event except call.completed also has event_id, occurred_at and org_id at the top level. call.completed has no event ID: to remove duplicates, use event plus data.call_uuid as the key. For every other event, use event_id. The event name also arrives in the X-Finn-Event header. See webhook-security to verify signatures and webhook-retries for the delivery schedule.
Event catalog
| Event | Fires when |
|---|---|
call.completed | A call in your organization has ended, answered or not. For an answered call on a Finn with post-call analysis fields, Finn waits for the analysis first. Each endpoint gets it at most once per call. |
contact.created | A new contact was added: in Contacts, as a lead (a form, the leads webhook, added by hand), or by a call or message from a number Finn didn't know. Adding a deleted contact again counts. |
contact.assigned | A contact got a new owner, or lost its owner. Someone in the app changed it, or an assignment rule, a rep calling an unowned contact or a Sales AI hand-off set it. |
contact.stage_changed | A contact moved to another stage. Someone changed it, or a call or message moved it (a stage hint or an opt-out). |
lead.created | A contact came in as a lead: added by hand, through the leads webhook, from a Sales AI hand-off, or a new contact from a Contacts form. A contact you already had can come in as a lead again, and each such arrival is one event. A form submitted by a contact you already had isn't one. |
lead.status_changed | A lead's status changed: someone changed it, a first outbound call or message moved a New lead to Contacted, or a won or lost stage moved it. |
contact.updated | A contact's name, tags, email, stage, owner, lead status, substatus, close reason or custom fields changed, in the app or through PATCH /api/v1/contacts/{id}. One event per edit. |
contact.substatus_changed | A lead's substatus changed, in workspaces with substatuses. |
contact.section_row_added | A row was added to a contact's section field (a list such as visits or policies), in workspaces with record kinds. |
contact.link_changed | A link field now points the contact at another record (a doctor, an agent), or at none, in workspaces with record kinds. |
document.status_changed | A contact's document changed status: missing, received, verified or rejected. |
case.created | A Finn Operator case opened. |
case.completed | A Finn Operator case reached a final status. |
approval.requested | A Finn Operator case asked a person for an approval. |
You pick the events when you add an endpoint. call.completed is picked by default. An endpoint gets only the events picked when it was added, so an older endpoint doesn't get the newer events.
A Finn's Data webhook URL and Inbound webhook URL can also receive a test request, post_call.test or inbound_call.test, when someone clicks Initialize next to the URL in Deployment settings. It isn't a real event and has a different body, with no data. Return 2xx and ignore it. See webhooks.
call.completed
The event isn't sent the moment the call hangs up. Calls that weren't answered, and calls on a Finn with no analysis fields, are usually sent within about 5 minutes. Answered calls on a Finn with analysis fields are sent as soon as the analysis is saved, usually within about 5 minutes, and after about an hour at the latest with whatever analysis exists. webhooks has the details. Most integrations are built on this event. webhook-post-call has the full payload and handler code. Summary of the data fields:
| Field | Type | Notes |
|---|---|---|
call_uuid | UUID | The call. Use it with GET /api/v1/calls/{call_uuid}. |
finn_id | UUID or null | The Finn that handled the call. |
deployment_id | UUID or null | The deployment the call belongs to: the same id that GET /api/v1/deployments returns. null for calls that don't belong to a deployment, such as calls placed with POST /api/v1/calls. |
status | string or null | The call's final status. |
disconnect_reason | string or null | Why the call ended. |
direction | string or null | inbound or outbound. |
from_number, to_number | string or null | The two phone numbers on the call. to_number is in E.164 format. |
duration_seconds | integer or null | Call length in whole seconds. |
user_sentiment | string or null | Overall sentiment of the person called. |
call_successful | boolean or null | Whether the call met its goal, according to analysis. |
created_at, updated_at | ISO 8601 or null | When the call record was created and last updated. |
analysis | array | One entry per analysis field, newest first. Empty if no fields are set up. Can also be empty for a call that wasn't answered, or when analysis hadn't arrived after about an hour. |
status, disconnect_reason and user_sentiment can take new values at any time. Treat them as strings, not a fixed list.
Contact and lead events
contact.created, contact.assigned, contact.stage_changed, lead.created and lead.status_changed are sent usually within a minute of the change. Use them to keep a CRM, a spreadsheet or a Zapier or Make scenario in step with Finn's Contacts.
{
"event": "contact.stage_changed",
"event_id": "crm:stage:6f1c2a0e-8d1b-4b7a-9f3e-2c4d5e6f7a8b:1761563649000:resolved",
"created_at": "2026-10-27T11:14:11.000Z",
"occurred_at": "2026-10-27T11:14:09.000Z",
"org_id": "0b7c6a1e-2f3d-4e5a-8b9c-1d2e3f4a5b6c",
"data": {
"contact": {
"id": "6f1c2a0e-8d1b-4b7a-9f3e-2c4d5e6f7a8b",
"name": "Ravi Rao",
"phones": ["+919800000091"],
"emails": ["[email protected]"],
"owner": { "id": "a3f9c2d1-5b6e-4c7d-8e9f-0a1b2c3d4e5f", "name": "Asha" },
"stage": "resolved",
"stage_kind": "won",
"lead_status": "converted",
"lead_source": "Website form",
"sources": ["lead"],
"tags": ["expo"],
"fields": { "company": "Acme" },
"custom_fields": { "budget": 12000 },
"created_at": "2026-10-20T09:02:44.000Z",
"updated_at": "2026-10-27T11:14:09.000Z"
},
"change": {
"from": "follow_up",
"to": "resolved",
"by": "person",
"user": { "id": "a3f9c2d1-5b6e-4c7d-8e9f-0a1b2c3d4e5f", "name": "Asha" }
}
}
}
| Field | Type | Notes |
|---|---|---|
event_id | string | The same for every delivery of one change. Use it to remove duplicates: an event is sent at least once, and rarely twice. |
occurred_at | ISO 8601 | When the change happened. |
org_id | UUID | Your organization. |
data.contact | object | The contact as it is when the event is sent, not when the change happened. A contact merged into another since is sent as the contact it went into. |
data.change | object | contact.assigned, contact.stage_changed and lead.status_changed only. See below. |
data.lead | object | lead.created only: via (how it arrived, such as manual, webhook or form) and source (the lead source, or null). |
data.contact fields:
| Field | Type | Notes |
|---|---|---|
id | UUID | The contact. |
name | string or null | |
phones | array of strings | E.164. The main number first, then any others. Empty for a contact with no phone. |
emails | array of strings | Lowercase. Empty when there's none. |
owner | object or null | { "id", "name" } of the team member who owns the contact. |
stage | string | The stage key, such as new or resolved. Your workspace can add its own. |
stage_kind | string | open, won or lost. |
lead_status | string or null | new, contacted, qualified, converted or lost. null for a contact that isn't a lead. |
lead_source | string or null | Where the lead came from. |
sources | array of strings | How the contact reached Finn, such as lead, manual or csv. |
tags | array of strings | |
fields | object | The contact's fields, such as company. |
custom_fields | object | Your workspace's custom fields, by key. |
created_at, updated_at | ISO 8601 |
data.change fields:
| Field | Type | Notes |
|---|---|---|
from, to | string, object or null | The stage key or lead status before and after. For contact.assigned, the owner before and after as { "id", "name" }, or null for no owner. |
by | string | person when someone in the app made the change, auto when a rule, a call, a message or an agent did. |
user | object or null | { "id", "name" } of the person, when by is person. |
stage, lead_status, sources, via and by can take new values. Treat them as strings, not a fixed list.
How they're sent:
- In order per endpoint. An endpoint gets its events other than
call.completedone at a time, in the order they happened, socontact.createdarrives before thelead.createdof the same lead. An event that fails all its attempts doesn't hold up the next. - Same signature, attempts and headers as
call.completed. Up to 3 attempts, 1 and 2 seconds apart. If all 3 fail, Finn doesn't send that event again. The Deliveries list shows each one. - At least once. If Finn can't record your
2xx, it sends the event again later with the sameevent_id. Remove duplicates onevent_id. - Not for list uploads. Contacts added by a CSV or list upload, or by an audience import, don't produce
contact.createdorlead.created. You already have that file. Later changes to them do produce events, includingcontact.assignedwhen an assignment rule gives them an owner. - Not for deleted contacts. An event whose contact was deleted before it was sent isn't sent. Its delivery shows
failedwith the errorcontact_deleted. - Not after a day. An event that couldn't be sent within 24 hours is dropped (
expired). - No backfill. A new endpoint gets the changes made from about 30 seconds after you add it.
More contact events
These have the same body as the contact and lead events above, with data.contact plus one more part.
contact.updated adds data.changed_fields: the sorted keys of what changed, never the values. Keys are name, tags, email, stage, owner, lead_status, substatus, close_reason and custom_fields.<key>. Read the new values in data.contact. An edit that changed nothing sends no event. A stage or owner change also sends contact.stage_changed or contact.assigned; pick only the events you need.
{
"event": "contact.updated",
"event_id": "crm:updated:6f1c2a0e-8d1b-4b7a-9f3e-2c4d5e6f7a8b:1762164902000",
"created_at": "2026-11-03T10:15:04.000Z",
"occurred_at": "2026-11-03T10:15:02.000Z",
"org_id": "0b7c6a1e-2f3d-4e5a-8b9c-1d2e3f4a5b6c",
"data": {
"contact": { "id": "6f1c2a0e-8d1b-4b7a-9f3e-2c4d5e6f7a8b", "name": "Ravi Rao", "stage": "qualified", "custom_fields": { "budget": 15000 } },
"changed_fields": ["custom_fields.budget", "stage", "tags"]
}
}
(data.contact shortened here; it has every field in the table above.)
contact.substatus_changed adds data.substatus: { "stage", "from", "to", "by", "via" }. In workspaces with substatuses, contact.stage_changed also carries data.substatus as { "from", "to" }. Sent for leads only.
contact.section_row_added adds data.section_row: { "field_key", "row_id", "data", "supersedes_id", "meta" }. supersedes_id is the row this one corrects, or null.
contact.link_changed adds data.link: { "field_key", "from_contact_id", "old_to", "new_to" }. old_to and new_to are record IDs or null.
document.status_changed adds data.document:
| Field | Type | Notes |
|---|---|---|
type | object | { "id", "key", "name" } of the document type. |
from, to | string or null | missing, received, verified or rejected. from is null for a new document. |
via | string | user, rule, link, form, ai or import. |
reason | string | When given, such as for a rejection. |
file_id, version | The file, when there is one. | |
requested_count | integer | How many times the document was asked for. |
File names and file contents are never sent.
In workspaces with record kinds, data.contact also has kind, ref_no, substatus and substatus_label. lead.created, lead.status_changed and contact.substatus_changed are sent only for leads.
Finn Operator events
case.created, case.completed and approval.requested follow the same shape and delivery rules. data.contact is the case's contact, or null when the case has none or it was deleted: these events are sent either way.
{
"event": "approval.requested",
"event_id": "approval_requested:5d2e8f1a-3b4c-4d6e-9f0a-1b2c3d4e5f6a",
"created_at": "2026-11-03T09:00:03.000Z",
"occurred_at": "2026-11-03T09:00:00.000Z",
"org_id": "0b7c6a1e-2f3d-4e5a-8b9c-1d2e3f4a5b6c",
"data": {
"contact": null,
"approval": {
"id": "5d2e8f1a-3b4c-4d6e-9f0a-1b2c3d4e5f6a",
"case_id": "3c9b1f7e-2a4d-4e6b-8c1d-5f7a9b0c2d3e",
"agent": { "id": "1b2c3d4e-5f6a-4b7c-8d9e-0f1a2b3c4d5e", "name": "Renewals" },
"proposal_summary": "Email the renewal quote",
"due_at": "2026-11-04T09:00:00.000Z",
"link": "https://www.hirefinn.ai/..."
}
}
}
| Event | event_id | Extra data |
|---|---|---|
case.created | case_created:<case_id> | data.case: { "id", "agent": { "id", "name" } or null, "status", "outcome_key", "title" }. |
case.completed | case_completed:<case_id> | data.case, same shape, with the final status and outcome_key. |
approval.requested | approval_requested:<approval_id> | data.approval: { "id", "case_id", "agent", "proposal_summary", "due_at", "link" }. |
Each is sent once per case or approval. To act on an approval, read it with GET /api/v1/approvals/{id} and decide it with an approvals:write key; see api-approvals.
Events that don't exist
Some integrations expect these events. Finn doesn't send them:
| Event | What to use instead |
|---|---|
call.started | Your 202 response from POST /api/v1/calls |
call.analyzed | call.completed waits for analysis on answered calls, so it already includes analysis |
call.transferred | Not available |
deployment.completed | Poll GET /api/v1/deployments/{id} |
approval.decided | Poll GET /api/v1/approvals/{id}, or wait for case.completed |
contact.deleted | Not available |
wallet.low_balance | Poll GET /api/v1/wallet and read low_balance |
What breaks
| Symptom | Cause |
|---|---|
A handler expecting type or id at the top level throws | The fields are event and created_at. There's no top-level id; every event but call.completed has event_id. |
A handler built for call.completed throws on another event | Check event (or the X-Finn-Event header) before you read data, and answer events you don't handle with 200. |
| A case event has no contact | data.contact is null when the case has none or it was deleted. |
| A contact change never produces an event | The endpoint doesn't list that event, the contact isn't a lead (for lead.* and contact.substatus_changed), the contact came from a list upload, the contact was deleted before the event was sent, or all 3 attempts failed. |
| A call never produces an event | All 3 delivery attempts failed, or the call ended before you added the endpoint. Older calls aren't backfilled. Check GET /api/v1/calls/{call_uuid}. |
analysis is empty | The Finn has no analysis fields set up, the call wasn't answered, or analysis hadn't arrived after about an hour. GET /api/v1/calls/{call_uuid} returns analysis that arrives later. |
Fields are null | The call record wasn't available when the payload was built. Read the call with GET /api/v1/calls/{call_uuid}. |
Related: webhooks, webhook-post-call, webhook-security, webhook-retries, api-webhooks.