> ## Documentation Index
> Fetch the complete documentation index at: https://docs.useorbitcrm.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Webhooks Overview

> Webhooks send your application a signed HTTP POST when something happens in Orbit: a form submission, a new or changed contact, company, deal or task, a meeting, or a call.

## How webhooks work

**1. Register an endpoint**

Add an HTTPS URL in **Settings → Integrations → Webhooks**, or create a subscription with the [Webhooks API](https://docs.useorbitcrm.ai/developers/api/webhooks.md).

**2. Choose events**

Pick from the [event catalog](https://docs.useorbitcrm.ai/developers/webhooks/events.md): submissions, contacts, companies, deals, tasks, meetings and calls. Each object needs its read scope when you subscribe with an API key or an app.

**3. Receive signed deliveries**

When an event happens, Orbit POSTs a JSON envelope to your URL, signed with the webhook's secret.

**4. Respond with 2xx**

Return any 2xx status within the webhook's timeout (30 seconds by default). Anything else is retried.

## What you can subscribe to

| Object | Events | Scope to subscribe |
| - | - | - |
| Submissions | `submission.created`, `submission.qualified`, `submission.updated`, `submission.deleted` | `submissions:read` |
| Contacts | `contact.created`, `contact.updated`, `contact.archived`, `contact.deleted`, `contact.tag_added`, `contact.tag_removed`, `contact.association_changed`, `contact.job_changed` | `contacts:read` |
| Companies | `company.created`, `company.updated`, `company.deleted`, `company.restored`, `company.merged`, `company.tag_added`, `company.tag_removed` | `companies:read` |
| Deals | `deal.created`, `deal.updated`, `deal.stage_changed`, `deal.won`, `deal.lost`, `deal.reopened`, `deal.deleted`, `deal.restored`, `deal.merged`, `deal.tag_added`, `deal.tag_removed`, `deal.association_changed`, `deal.line_item_added`, `deal.line_item_updated`, `deal.line_item_removed` | `deals:read` |
| Products | `product.created`, `product.updated`, `product.archived`, `product.restored` | `deals:read` |
| Quotes | `quote.created`, `quote.updated`, `quote.published`, `quote.viewed`, `quote.accepted`, `quote.voided`, `quote.deleted` | `deals:read` |
| Tasks | `task.created`, `task.updated`, `task.completed`, `task.deleted`, `task.restored` | `tasks:read` |
| Meetings | `meeting.booked`, `meeting.rescheduled`, `meeting.cancelled` | `meetings:read` |
| Calls | `call.completed`, `call.transcribed`, `call.analyzed` | `dialer:read` |
| Lists | `list.member_added`, `list.member_removed` | `crm:read` |

Events fire however the change was made: in the dashboard, by an import, through the API or MCP, or by a workflow. Submission events can be limited to one form; every other event is workspace-wide. `GET /api/v1/webhooks/events` returns the same catalog with the scopes your credential holds.

## The envelope

Every delivery has the same top-level shape. `data` is specific to the event and is documented per object on the [events page](https://docs.useorbitcrm.ai/developers/webhooks/events.md).

```json theme={null}
{
  "id": "5f0c1f2e-9c0a-5a3b-8d1e-6b2f0c9a4d11",
  "event": "deal.stage_changed",
  "object": "deal",
  "object_id": "9a1d3c4e-2b5f-4a6c-8d7e-0f1a2b3c4d5e",
  "team_id": "0c2b4d6e-8f1a-4c3e-9b5d-7a9c1e3f5b7d",
  "occurred_at": "2026-10-27T14:30:05.120Z",
  "timestamp": "2026-10-27T14:30:05.120Z",
  "actor": { "user_id": "d4e5f6a7-b8c9-4d0e-9f1a-2b3c4d5e6f7a", "app_id": null },
  "source": "app",
  "data": {
    "deal_id": "9a1d3c4e-2b5f-4a6c-8d7e-0f1a2b3c4d5e",
    "deal": { "id": "9a1d3c4e-...", "name": "Acme renewal", "amount": 12000, "currency": "USD", "status": "open", "stage_id": "..." },
    "changes": { "stage_id": { "from": "1f2e3d4c-...", "to": "2a3b4c5d-..." } },
    "from_stage": { "id": "1f2e3d4c-...", "name": "Qualified", "kind": "open", "pipeline_id": "..." },
    "to_stage": { "id": "2a3b4c5d-...", "name": "Proposal", "kind": "open", "pipeline_id": "..." }
  }
}
```

| Field | Meaning |
| - | - |
| `id` | The event's id. A retry of the same event carries the same id, so you can de-duplicate on it |
| `event` | `<object>.<action>` from the catalog |
| `object`, `object_id` | What the event is about |
| `team_id` | The workspace |
| `occurred_at` | When the change happened. `timestamp` is the same instant, kept for subscribers of the first webhooks |
| `actor.user_id` | The person whose action caused the event, when one did in the dashboard; `null` for imports, workflows, the API and system changes |
| `actor.app_id` | The connected app (OAuth app) whose API or MCP call caused the event; `null` for everything else, including API keys. An app keeping another system in sync skips the events that carry its own id. See [Changes your app made](#changes-your-app-made) |
| `source` | `app` for ordinary activity (the dashboard, forms, workflows, the API one record at a time); `import` for a record created or updated by a bulk import or a CRM migration. See [Imports](#imports) |
| `data` | The event's payload: the record, `changes` for updates (`{ field: { from, to } }`), and event-specific extras |

## Imports

A CSV import, the API's bulk import endpoints and a CRM migration can create tens of thousands of records in minutes. By default their events are **not delivered**: no webhook or connected app receives them, so a migration never turns into thousands of deliveries nobody asked for.

A workspace owner or admin can change that under **Settings → Integrations → Connected apps → Send imported records to integrations**. Turning it on is an explicit acceptance (recorded with who, when and the wording), applies to every webhook and connected app in the workspace, and only affects imports that run after it. Those deliveries carry `"source": "import"`, so a receiver that wants to treat them differently (skip them, batch them, not bill for them) can tell them apart. Records added one at a time, by a person, a form, a workflow or the API, always carry `"source": "app"` and are always delivered.

Workflows are unaffected: an import never starts a workflow unless the person importing picks one in the import dialog.

## Changes your app made

When a connected app changes a contact, company, deal, task or tag through the API (or an import it runs), the events that change raises carry the app's id in `actor.app_id`: the id of your OAuth app, the one `GET /api/v1/me` returns. Your app receives them like any other event. An app that keeps another system in sync with Orbit should skip them: they describe a write it has already applied on its side, and sending them back is how two systems end up overwriting each other in a loop.

```js theme={null}
if (event.actor.app_id === MY_APP_ID) return // our own write coming back
```

A write that leaves every value as it was raises no event at all, so an echo that slips through stops after one round. Events raised by code rather than a record change (`submission.created`, `submission.qualified` and the meeting events) carry `app_id: null` for now. See [Build a two-way sync](https://docs.useorbitcrm.ai/apps/two-way-sync.md).

## Headers

| Header | Value |
| - | - |
| `X-Orbit-Signature` | `sha256=<HMAC-SHA256 of the raw body with the webhook secret>` (see [security](https://docs.useorbitcrm.ai/developers/webhooks/security.md)) |
| `X-Orbit-Timestamp` | The envelope's `occurred_at`: when the change happened, not when this attempt was sent (a retry keeps it) |
| `X-Orbit-Event` | The event name |
| `X-Orbit-Event-ID` | The envelope's `id`; the same on every attempt |
| `X-Orbit-Delivery-ID` | Unique per attempt |
| `X-Orbit-Attempt` | `1` for the first attempt, then `2`, `3`, ... |
| `X-Orbit-Webhook-ID` | The subscription the delivery belongs to |
| `User-Agent` | `Orbit-Webhooks/1.0` |

## Delivery

* **Timing.** Form submissions and meeting events are sent as they happen. Contact, company, deal, task, call and submission-change events are collected from the database and sent within about a minute.
* **Retries.** A delivery that fails (a timeout, a connection error, a 5xx, or a 429) is retried with exponential backoff starting from the webhook's retry delay (60 seconds by default), capped at 5 minutes between attempts, up to the webhook's retry count (3 by default, 5 at most). A `Retry-After` header on a 429 is honoured. Other 4xx responses are not retried: fix the endpoint and the next event will get through.
* **Auto-disable.** After 10 consecutive events that failed every attempt, the webhook is switched off and `last_error` records why. Switch it back on in the dashboard or with `PATCH /api/v1/webhooks/:id`.
* **At least once.** Treat deliveries as at-least-once and de-duplicate on `id`. Events are not guaranteed to arrive in order; use `occurred_at` when order matters.
* **Logs.** Every attempt is recorded. See them under the webhook in the dashboard or with `GET /api/v1/webhooks/:id/deliveries`.

## Learn more

> [card] [Events](https://docs.useorbitcrm.ai/developers/webhooks/events.md)
>
> Every event and its payload

> [card] [Security](https://docs.useorbitcrm.ai/developers/webhooks/security.md)
>
> Verify signatures and guard against replays

> [card] [Webhooks API](https://docs.useorbitcrm.ai/developers/api/webhooks.md)
>
> Create and manage subscriptions from code

> [card] [Webhooks for apps](https://docs.useorbitcrm.ai/apps/webhooks.md)
>
> Subscribe on behalf of the workspaces that installed your app


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.