# Outbound webhooks

Source: https://docs.qiick.io/externa/webhooks

Externa **pushes** domain events to a single HTTPS endpoint you configure under **Settings → Integrations** (`/settings/project/integrations`). Delivery is queued and HMAC-signed with a project webhook secret (not an API key). Disabled when the URL is empty; **refused** when a URL is set without a signing secret. For human-readable Slack/Telegram alerts on the same events, see [Integrations](https://docs.qiick.io/externa/integrations).

> **Related**
> [Project settings](https://docs.qiick.io/externa/project-settings) · [Operations (queue worker)](https://docs.qiick.io/externa/operations) · [Public CMS API](https://docs.qiick.io/externa/public-cms-api) · [Security checklist](https://docs.qiick.io/externa/security-checklist) · [externa-bruno](https://github.com/qiick-io/externa-bruno) `Verify Webhook Signature`

> Outbound webhooks are **Externa → your URL**. There is no Externa route you `POST` to in order to “receive” them. Do not confuse this with the [AI collection-import webhook](https://docs.qiick.io/externa/ai-api) (`POST /ai/webhooks/collection-import`), which is an inbound HMAC-signed endpoint.

## Configure

1. Admin → **Settings** → **Integrations** (`/settings/project/integrations`)
2. Set **Webhook URL** (e.g. `https://example.com/webhooks/externa` or a temporary [webhook.site](https://webhook.site) URL)
3. **Generate secret** (or paste your own) and save — copy the secret into your receiver; Externa encrypts it at rest and never re-shows it in Inertia props
4. Optionally **Send test event** (`type: ping`) — route `project.integrations.webhook-test` (deprecated alias `project.webhook-test` kept for one minor); requires a queue worker

Leave the URL empty to disable outbound events. Empty secret on save keeps the existing secret.

> A URL **without** a signing secret does **not** deliver. The dispatcher refuses to queue, and `DeliverOutboundWebhookJob` refuses to POST (no empty-key HMAC). Configure both URL and secret, then use **Send test event**.

> Delivery uses `DeliverOutboundWebhookJob`. Run a queue worker (`composer run dev` locally, or `php artisan queue:work` in production). See [Operations](https://docs.qiick.io/externa/operations).

## SSRF guard

From core **1.3.0** ([#118](https://github.com/qiick-io/externa-core/issues/118) / [PR #267](https://github.com/qiick-io/externa-core/pull/267)), webhook and Slack notifier URLs are validated with `SafeRemoteUrlValidator` **on save** and again **at delivery**. Delivery uses `Http::withoutRedirecting()`.

| Setting                                                | Default | Effect                                                         |
| ------------------------------------------------------ | ------- | -------------------------------------------------------------- |
| `WEBHOOKS_ALLOW_PRIVATE_HOSTS` (`config/webhooks.php`) | `false` | Reject loopback, RFC1918, link-local, and other reserved hosts |

**Breaking for LAN targets:** a previously saved `http://192.168.x.x/...` or `http://10.x.x.x/...` URL fails validation / delivery until you set:

```bash
WEBHOOKS_ALLOW_PRIVATE_HOSTS=true
php artisan config:clear   # or config:cache after deploy
```

Public `https://` receivers need no change. Telegram notifiers keep a fixed host (`api.telegram.org`) and still disable redirects.

> `WEBHOOKS_ALLOW_PRIVATE_HOSTS=true` disables the SSRF block for private ranges. Use only when Externa must reach an internal automation host you control.

## Envelope

```json
{
  "id": "evt_01h…",
  "type": "item.updated",
  "created_at": "2026-07-24T12:00:00Z",
  "data": {
    "collection_id": 1,
    "collection_slug": "posts",
    "item_id": 42
  }
}
```

Bodies are **minimal** (ids / slug only). Full record dumps are out of scope for v1. Empty `data` is encoded as `{}` (object), not `[]`.

### Event types (v1)

| Type                                                               | When                                                                                                                                                                                      |
| ------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `item.created` / `item.updated`                                    | After item field sync                                                                                                                                                                     |
| `item.published`                                                   | Draft promoted (manual or scheduled; payload may include `scheduled: true`)                                                                                                               |
| `item.publish_failed`                                              | Scheduled publish failed (approval invalidated, promote error, …); item keeps `schedule_error` / `schedule_failed_at` ([core PR #303](https://github.com/qiick-io/externa-core/pull/303)) |
| `item.submitted` / `item.approved` / `item.rejected`               | Editorial approvals transitions ([Approvals](https://docs.qiick.io/externa/approvals); reject includes note)                                                                                                   |
| `item.deleted`                                                     | Soft **or** force delete (same event type — consumers cannot distinguish)                                                                                                                 |
| `item.restored`                                                    | Item restore                                                                                                                                                                              |
| `file.created` / `file.updated` / `file.deleted`                   | File manager create/update/delete (**no** `file.restored`)                                                                                                                                |
| `collection.created` / `collection.updated` / `collection.deleted` | Collection lifecycle (**no** `collection.restored`; soft/force both emit `deleted`)                                                                                                       |
| `ping`                                                             | Settings “Send test event”                                                                                                                                                                |

> The same domain events can also go to Slack or Telegram as human-readable messages, without HMAC. See [Slack & Telegram notifiers](https://docs.qiick.io/externa/integrations) or [Project settings — Notifiers](https://docs.qiick.io/externa/project-settings#notifiers-slack-telegram).

> Only **items** emit `*.restored`. File restore (`FileService::restore`) and collection restore do not dispatch outbound webhook events.

### Bulk import

CSV / remote JSON imports wrap row writes in `OutboundWebhookDispatcher::withoutWebhooks()` so a large import does not flood your endpoint.

## Headers

| Header                   | Value                                                                 |
| ------------------------ | --------------------------------------------------------------------- |
| `Content-Type`           | `application/json`                                                    |
| `X-Externa-Signature-V2` | `sha256=<hmac_sha256("{timestamp}.{raw_body}", secret)>` (**prefer**) |
| `X-Externa-Signature`    | `sha256=<hmac_sha256(raw_body, secret)>` (legacy; one minor)          |
| `X-Externa-Event-Id`     | Same as payload `id`                                                  |
| `X-Externa-Timestamp`    | Unix seconds when the job ran (same value used in V2)                 |
| `User-Agent`             | `Externa-Webhooks/1.0`                                                |

**Prefer V2:** HMAC-SHA256 over the ASCII string `"{timestamp}.{raw JSON body}"` (timestamp = `X-Externa-Timestamp`, then a literal `.`, then the exact body bytes). Prefix the hex digest with `sha256=`. Reject skew beyond your tolerance and treat each signature as single-use if you need replay protection on the receiver.

> `X-Externa-Signature` (body-only HMAC) remains for **one minor** after this change ([core#273](https://github.com/qiick-io/externa-core/issues/273)). Switch receivers to `X-Externa-Signature-V2` + timestamp binding; the legacy header will be removed in a later release.

Timeouts: connect 3s, total 5s. Failed deliveries retry (3 tries, backoff 10s / 30s / 60s). Exhausted failures are logged; they do not break the admin/API write path.

## Delivery status (admin)

**Settings → Integrations** → **Outbound webhooks** shows:

- **Last successful delivery** and **last failed delivery** (type, time, attempts, HTTP status / error message)
- A **recent** ring buffer of the last 10 outcomes (best-effort under concurrent workers)

Use **Send test event** after fixing a receiver; the cards update once the queue worker finishes the job.

## Verify HMAC (on your receiver)

Prefer **V2**: sign `timestamp + '.' + rawBody` (not a re-encoded JSON object). Compare with a constant-time equality check. Reject timestamps outside your skew window.

### Node

```js
import crypto from 'node:crypto'

function verifyV2(rawBody, secret, timestamp, signatureHeader) {
  const expected =
    'sha256=' +
    crypto
      .createHmac('sha256', secret)
      .update(`${timestamp}.${rawBody}`)
      .digest('hex')
  const a = Buffer.from(expected)
  const b = Buffer.from(signatureHeader || '')
  return a.length === b.length && crypto.timingSafeEqual(a, b)
}
```

### PHP

```php
$expected = 'sha256='.hash_hmac('sha256', $timestamp.'.'.$rawBody, $secret);
hash_equals($expected, $signatureHeaderV2);
```

## Try / test

### End-to-end (recommended)

1. Open [webhook.site](https://webhook.site) (or run a local receiver) and copy its unique URL
2. Admin → **Settings** → **Project** → paste as **Webhook URL**, generate/save secret, keep a copy of the secret
3. Start a queue worker
4. Click **Send test event**, or create/update an item in the admin
5. Inspect the POST on webhook.site: JSON envelope + `X-Externa-*` headers
6. Point the URL at your real receiver and verify HMAC with the snippets above

### Practice signing with Bruno (optional)

The [externa-bruno](https://github.com/qiick-io/externa-bruno) collection includes **`PublicApi/Webhooks/Verify Webhook Signature`**.

> That request is a **docs helper / sample payload**, not a live Externa API. Its default URL is `{{base_url}}/__webhook_receiver_docs_only__` — an intentional placeholder. Sending it against externa-core **404s**. Replace the URL with **your** receiver (or webhook.site) if you want to POST the sample body yourself.

Use it to:

- See the envelope shape and header names Externa sends
- Compute `sha256=<hmac>` locally (same secret as Integrations → `webhook_secret` / `EXTERNA_WEBHOOK_SECRET` in Bruno `.env`)
- Set `X-Externa-Signature` on the request and `POST` **to your receiver** to exercise verification code

Bruno is acting as a **client toward your webhook endpoint**, not as a caller of Externa’s Public API.

Full Bruno notes: [externa-bruno README — Webhooks helper](https://github.com/qiick-io/externa-bruno#webhooks-helper).

## Recipes (Make / n8n / Zapier / build hooks)

Externa POSTs to **your** HTTPS URL. Point the project webhook at an automation “Custom webhook” / “Webhook” trigger, verify HMAC (or skip in a private network), then branch on `type`.

### Make (Integromat)

1. Create a scenario → **Webhooks → Custom webhook** → copy the URL
2. Paste into Externa **Settings → Integrations** → generate/save secret → **Send test event**
3. In Make, add a **Router** (or filter) on `type` — e.g. `item.updated` / `item.created`
4. Downstream modules: Slack, HTTP to rebuild, CRM, etc.
5. Optional: verify `X-Externa-Signature` in a Code module with the Node snippet above

### n8n

1. **Webhook** node (POST) → Production URL into Externa
2. **IF** / **Switch** on `{{$json.type}}` (e.g. equals `item.updated`)
3. Next nodes: HTTP Request (Vercel/Netlify deploy hook), Slack, database, …
4. For HMAC: **Crypto** node or Code node comparing `sha256=` + HMAC of the **raw body** to `X-Externa-Signature`

### Zapier

1. **Webhooks by Zapier → Catch Hook** → copy URL into Externa
2. Filter step: `type` is one of `item.created`, `item.updated`, …
3. Action: Slack / email / HTTP — same pattern as Make

> Use the generic webhook trigger. Externa does not ship a marketplace connector; the signed HTTP envelope is the integration surface.

### Static host rebuild (Vercel / Netlify)

Treat the platform **Deploy Hook / Build Hook** URL as the webhook consumer when you only need “content changed → rebuild”:

1. Create a Deploy Hook in Vercel (or Build Hook in Netlify)
2. Paste that URL as the Externa webhook URL and set a signing secret
3. Every matching domain event triggers a rebuild

> Vercel/Netlify deploy hooks usually **ignore** `X-Externa-Signature`. Anyone with the hook URL can trigger a build. Prefer a thin receiver (Make/n8n/your API) that verifies HMAC, filters `type`, then calls the deploy hook. Direct hook URL is fine for private staging only.

Narrower CDN purge / rebuild cookbooks: [Webhook rebuild & CDN purge](https://docs.qiick.io/externa/webhook-rebuild-purge).

## vs inbound API keys

|           | Outbound webhooks                    | Public CMS API keys          |
| --------- | ------------------------------------ | ---------------------------- |
| Direction | Externa → your URL                   | Your app → Externa `/api/v1` |
| Secret    | Project `webhook_secret` (encrypted) | `ek_…` key (hashed at rest)  |
| Auth      | HMAC on body                         | `Authorization: Bearer`      |

Do **not** reuse an API key as the webhook signing secret.
