HTTP reference
Outbound webhooks
Externa pushes domain events to a single HTTPS endpoint you configure in Project settings. Delivery is queued, signed with a project webhook secret (not an API key), and disabled when the URL is empty.
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 token-auth endpoint.
Configure
- Admin → Settings → Project
- 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) — requires a queue worker
Leave the URL empty to disable outbound events. Empty secret on save keeps the existing secret.
Queue worker required
Delivery uses DeliverOutboundWebhookJob. Run a queue worker (composer run dev locally, or php artisan queue:work in production). See Operations.
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.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” |
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 | sha256=<hmac_sha256(raw_body, secret)> |
X-Externa-Event-Id | Same as payload id |
X-Externa-Timestamp | Unix seconds when the job ran |
User-Agent | Externa-Webhooks/1.0 |
Algorithm: HMAC-SHA256 over the exact raw JSON body bytes, using the project webhook secret. Prefix the hex digest with sha256=.
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.
Verify HMAC (on your receiver)
Sign the raw request body (not a re-encoded JSON object). Compare with a constant-time equality check.
Node
import crypto from 'node:crypto'
function verify(rawBody, secret, signatureHeader) {
const expected =
'sha256=' +
crypto.createHmac('sha256', secret).update(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', $rawBody, $secret);
hash_equals($expected, $signatureHeader);
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 Project settings →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.
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.