HTTP reference

Markdown

Outbound 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.

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

  1. Admin → Settings → Integrations (/settings/project/integrations)
  2. Set Webhook URL (e.g. https://example.com/webhooks/externa or a temporary 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.

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().

SettingDefaultEffect
WEBHOOKS_ALLOW_PRIVATE_HOSTS (config/webhooks.php)falseReject 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)

TypeWhen
item.created / item.updatedAfter item field sync
item.publishedDraft promoted (manual or scheduled; payload may include scheduled: true)
item.publish_failedScheduled publish failed (approval invalidated, promote error, …); item keeps schedule_error / schedule_failed_at (core PR #303)
item.submitted / item.approved / item.rejectedEditorial approvals transitions (Approvals; reject includes note)
item.deletedSoft or force delete (same event type — consumers cannot distinguish)
item.restoredItem restore
file.created / file.updated / file.deletedFile manager create/update/delete (no file.restored)
collection.created / collection.updated / collection.deletedCollection lifecycle (no collection.restored; soft/force both emit deleted)
pingSettings “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

HeaderValue
Content-Typeapplication/json
X-Externa-Signature-V2sha256=<hmac_sha256("{timestamp}.{raw_body}", secret)> (prefer)
X-Externa-Signaturesha256=<hmac_sha256(raw_body, secret)> (legacy; one minor)
X-Externa-Event-IdSame as payload id
X-Externa-TimestampUnix seconds when the job ran (same value used in V2)
User-AgentExterna-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

  1. Open 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 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_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.

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

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”:

  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

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 webhooksPublic CMS API keys
DirectionExterna → your URLYour app → Externa /api/v1
SecretProject webhook_secret (encrypted)ek_… key (hashed at rest)
AuthHMAC on bodyAuthorization: Bearer

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

Previous
MCP server