HTTP reference
MarkdownOutbound 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.
Related
Project settings · Operations (queue worker) · Public CMS API · Security checklist · externa-bruno Verify Webhook Signature
Direction
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 (POST /ai/webhooks/collection-import), which is an inbound HMAC-signed endpoint.
Configure
- Admin → Settings → Integrations (
/settings/project/integrations) - Set Webhook URL (e.g.
https://example.com/webhooks/externaor a temporary webhook.site URL) - 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
- Optionally Send test event (
type: ping) — routeproject.integrations.webhook-test(deprecated aliasproject.webhook-testkept for one minor); requires a queue worker
Leave the URL empty to disable outbound events. Empty secret on save keeps the existing secret.
Signing secret required
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.
Queue worker required
Delivery uses DeliverOutboundWebhookJob. Run a queue worker (composer run dev locally, or php artisan queue:work in production). See Operations.
SSRF guard
From core 1.3.0 (#118 / PR #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:
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.
Opt-in only on trusted networks
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
{
"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) |
item.submitted / item.approved / item.rejected | Editorial approvals transitions (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” |
Slack / Telegram
The same domain events can also go to Slack or Telegram as human-readable messages, without HMAC. See Slack & Telegram notifiers or Project settings — Notifiers.
Restore coverage
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.
Legacy signature deprecated
X-Externa-Signature (body-only HMAC) remains for one minor after this change (core#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
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
$expected = 'sha256='.hash_hmac('sha256', $timestamp.'.'.$rawBody, $secret);
hash_equals($expected, $signatureHeaderV2);
Try / test
End-to-end (recommended)
- Open webhook.site (or run a local receiver) and copy its unique URL
- Admin → Settings → Project → paste as Webhook URL, generate/save secret, keep a copy of the secret
- Start a queue worker
- Click Send test event, or create/update an item in the admin
- Inspect the POST on webhook.site: JSON envelope +
X-Externa-*headers - Point the URL at your real receiver and verify HMAC with the snippets above
Practice signing with Bruno (optional)
The externa-bruno collection includes PublicApi/Webhooks/Verify Webhook Signature.
Not an Externa route
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_SECRETin Bruno.env) - Set
X-Externa-Signatureon the request andPOSTto 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.
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)
- Create a scenario → Webhooks → Custom webhook → copy the URL
- Paste into Externa Settings → Integrations → generate/save secret → Send test event
- In Make, add a Router (or filter) on
type— e.g.item.updated/item.created - Downstream modules: Slack, HTTP to rebuild, CRM, etc.
- Optional: verify
X-Externa-Signaturein a Code module with the Node snippet above
n8n
- Webhook node (POST) → Production URL into Externa
- IF / Switch on
{{$json.type}}(e.g. equalsitem.updated) - Next nodes: HTTP Request (Vercel/Netlify deploy hook), Slack, database, …
- For HMAC: Crypto node or Code node comparing
sha256=+ HMAC of the raw body toX-Externa-Signature
Zapier
- Webhooks by Zapier → Catch Hook → copy URL into Externa
- Filter step:
typeis one ofitem.created,item.updated, … - Action: Slack / email / HTTP — same pattern as Make
No first-party Zapier/Make app
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”:
- Create a Deploy Hook in Vercel (or Build Hook in Netlify)
- Paste that URL as the Externa webhook URL and set a signing secret
- Every matching domain event triggers a rebuild
Hook URL vs HMAC
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.
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.