---
name: unified-webhooks
description: >-
  Set up Unified.to webhooks so an app receives created, updated and deleted
  records instead of polling: choose native or virtual per integration from
  live support data, create the subscription (interval, filters, fields,
  include_all backfill), write a handler that verifies sig256 and upserts
  idempotently, test it, and keep it healthy. Use when a developer asks to
  "get notified when contacts/deals/candidates/employees change", "sync data in
  real time", "add webhooks", or "backfill existing records".
license: MIT
metadata:
  category: webhooks
  api_base: https://api.unified.to
  docs: https://docs.unified.to/reference/webhooks
  docs_index: https://docs.unified.to/llms.txt
---

# Receive Unified.to webhooks

A Unified.to webhook delivers changed records for one connection and one object
(e.g. `crm_deal`) to a URL in your app. The same API and payload work across
integrations, but what each integration supports differs, so check before you
create one.

Sources for everything below: https://docs.unified.to/reference/webhooks,
https://docs.unified.to/concepts/virtual_webhooks and
https://docs.unified.to/guides/how_to_create_and_configure_webhooks. If
something here disagrees with them, the docs win.

## Step 1: Check what the integration supports

Look up the integration (Core MCP `list_unified_integrations` with
`type: "<integration_type>"`, or
`GET https://api.unified.to/unified/integration?type=<integration_type>`, or
`https://docs.unified.to/integrations/<integration_type>.md`) and read, for
your object:

- `support.<object>.webhook_events`: for each of `created`, `updated`,
  `deleted`, whether it is `native`, `virtual` or both. An event that is not
  listed is not supported.
- `support.<object>.virtual_webhook_*`: filters a virtual webhook accepts.
- `support.<object>.list_updated_gte`: whether polling with `updated_gte` is a
  fallback.

## Step 2: Choose the type and event

| | Native | Virtual |
| --- | --- | --- |
| How | The integration pushes events; Unified.to forwards them | Unified.to checks for changes every `interval` minutes and sends what changed |
| Delay | As the integration sends them | Up to the interval (minimum 1 minute on paid plans, 60 on the free/trial plan) |
| Events | `created`, `updated`, `deleted` where supported | `created`, `updated`; `deleted` where the integration supports it |
| Retries | 3 immediate retries, then the webhook is marked unhealthy and the event is not resent | 3 immediate retries, then progressive backoff for about two weeks; each retry re-reads the data |
| Cost | One API call per successful delivery | One API call per page of changes delivered; no charge for checks that find nothing |

- Prefer **native** when you need changes as soon as they happen and the
  integration supports it.
- Use **virtual** when native isn't supported, when a delay of a minute or more
  is fine, or when you want delivery to recover from your endpoint being down.
- **Event:** `updated` also fires for new records. To track both and tell them
  apart, subscribe to `updated` and compare `created_at` with `updated_at`
  (equal means new). Subscribe to `deleted` separately where it is supported.
- `webhook_type` is optional on create; if you leave it out, Unified.to picks
  a supported type. Asking for a type the integration doesn't support returns
  `400`. The type can't be changed later; delete and recreate the webhook.

## Step 3: Create the webhook

One webhook per connection, object and event:

```http
POST https://api.unified.to/unified/webhook?include_all=true
Authorization: Bearer <workspace API key>
Content-Type: application/json

{
  "hook_url": "https://yourapp.com/webhooks/unified",
  "connection_id": "<connection ID>",
  "object_type": "crm_deal",
  "event": "updated",
  "webhook_type": "virtual",
  "interval": 5,
  "fields": "id,name,amount,stage,updated_at",
  "filters": { "company_id": "12345" }
}
```

(Core MCP: `create_unified_webhook` with the same fields. It starts sending
data to a URL, so confirm with the developer first.)

- **Required:** `connection_id`, `object_type` (`<category>_<object>`),
  `event`, plus `hook_url`.
- `include_all=true` sends all existing records first (initial sync), then
  changes. Leave it out if you already have the data; a large initial sync
  can take a long time. The interval doesn't affect the initial sync.
- `interval`: virtual only, in minutes. Ignored for native.
- `fields`: comma-separated fields to include, the same as on API calls.
  `raw` is not included unless you ask for it.
- `filters`: a string dictionary, e.g. `{ "company_id": "12345" }`. Use the
  filters the integration lists for the object. Filters it doesn't support are
  accepted but have no effect; a few integrations require one, and create
  returns an error naming it.

## Step 4: Handle deliveries

Each delivery is a `POST` with this body:

| Field | Meaning |
| --- | --- |
| `data` | Array of records, in the same shape as the matching API endpoint |
| `webhook` | The webhook object (use its `id` to manage it) |
| `nonce` | Random string, part of the signature |
| `sig256` | Base64 HMAC-SHA256 signature |
| `external_xref` | The value you set on the connection, e.g. your user or account ID |
| `type` | `INITIAL-PARTIAL`, `INITIAL-COMPLETE`, `VIRTUAL` or `NATIVE` |

The payload `type` is uppercase; the webhook's `webhook_type` is lowercase.
They are different fields. `INITIAL-PARTIAL` comes with each page of the
initial sync, and `INITIAL-COMPLETE` with the last page (or an empty `data`)
when it finishes.

Rules for the handler:

1. **Verify `sig256`** before trusting anything:
   `base64(HMAC-SHA256(workspace secret, JSON of data + nonce))`. Serialize
   `data` exactly as received: same key order, no added spaces. The workspace
   secret is at https://app.unified.to/settings/api and stays server-side.
2. **Return `2xx` quickly**, then process asynchronously (queue the payload).
   Slow or failing responses trigger retries and can make the webhook
   unhealthy.
3. **Upsert by `id`.** Deliveries can repeat and arrive out of order, and some
   integrations send an event per property change. Use the record's latest
   state, e.g. skip an update whose `updated_at` is older than what you have.
4. **Route by `external_xref`** (or `webhook.connection_id`) to find the
   owning user or account in your app.

TypeScript (Express):

```typescript
import express from 'express';
import { createHmac, timingSafeEqual } from 'crypto';

const app = express();
app.use(express.json({ limit: '10mb' }));

function isValid(body: { data: unknown[]; nonce: string; sig256: string }) {
    const expected = createHmac('sha256', process.env.UNIFIED_WORKSPACE_SECRET!)
        .update(JSON.stringify(body.data))
        .update(String(body.nonce))
        .digest('base64');
    const a = Buffer.from(expected);
    const b = Buffer.from(body.sig256 || '');
    return a.length === b.length && timingSafeEqual(a, b);
}

app.post('/webhooks/unified', (req, res) => {
    if (!isValid(req.body)) return res.sendStatus(401);
    res.sendStatus(200);
    void queue.add('unified-webhook', req.body); // upsert by id in the worker
});
```

Python (Flask):

```python
import base64, hashlib, hmac, json, os
from flask import Flask, request

app = Flask(__name__)

def is_valid(body):
    data = json.dumps(body["data"], separators=(",", ":"), ensure_ascii=False)
    message = (data + str(body["nonce"])).encode("utf-8")
    digest = hmac.new(os.environ["UNIFIED_WORKSPACE_SECRET"].encode("utf-8"), message, hashlib.sha256).digest()
    return hmac.compare_digest(base64.b64encode(digest).decode(), body.get("sig256", ""))

@app.post("/webhooks/unified")
def unified_webhook():
    body = request.get_json()
    if not is_valid(body):
        return "", 401
    enqueue(body)  # upsert by id in a worker
    return "", 200
```

In Go, unmarshal `data` into `json.RawMessage` so its key order is kept; a
`map` reorders keys and the signature won't match.

## Step 5: Test it

- **Virtual webhooks:** trigger a check immediately with
  `PUT /unified/webhook/{id}/trigger` (Core MCP:
  `update_unified_webhook_trigger`) instead of waiting for the interval. It
  only sends something if records changed since the last check. Native
  webhooks return `400`.
- Create or update a record in the connected account and confirm a delivery
  arrives, the signature verifies, and the record is upserted once.
- Check deliveries and errors in the API call logs at app.unified.to, or with
  `list_unified_apicalls` (Core MCP) filtered by `webhook_id`.

## Step 6: Keep it healthy

- `is_healthy` turns false after retries run out or on a failure that can't be
  retried, such as broken connection auth. Don't use `checked_at` to judge
  health.
- Subscribe to the **Notifications webhook** in app.unified.to to hear about
  `WEBHOOK_UNHEALTHY` as soon as it happens.
- After fixing the cause, resume it with `PUT /unified/webhook/{id}/resume`.
  A virtual webhook continues from where it stopped (a few records near the
  failure may arrive again); a native webhook is re-subscribed, and events
  sent while it was unhealthy are not resent. The connection must be healthy,
  and a virtual webhook unhealthy for more than 7 days can't be resumed;
  delete and recreate it.
- Keep the endpoint reachable during deploys. Native deliveries that fail 3
  times are lost unless the integration sends the event again; if that
  matters, use a virtual webhook or reconcile with a periodic list call.
- If an integration doesn't support `deleted`, decide how deletions are
  noticed (for example a periodic full list) and tell the developer.

## Related skills

- Planning which vendors and objects to support:
  https://docs.unified.to/skills/unified-integration-planner/SKILL.md
- Debugging connections and webhooks:
  https://docs.unified.to/skills/unified-debug-webhooks-connections/SKILL.md
- Workspace setup and the first API call:
  https://docs.unified.to/skills/unified-quickstart/SKILL.md
