Webhooks turn any upstream system into a publisher. Sign a payload, POST it, and the engine maps it to content and rebuilds — no key exchange in the request path, because the signature is the auth.

The endpoint

POST https://your-host/webhooks/{integration}

{integration} is a slug you choose when you create the integration — not the name of a mapper. Each one has its own slug, its own secret, and one mapper behind it, so a site can have several: newsroom, docs-sync, partner-feed. Slugs are lowercase letters, digits, and hyphens.

php bin/fastsite webhook:create newsroom --site=blog --mapper=autoseo

That prints the full URL and the signing secret once. You can do the same from the dashboard, the REST API, or the create_webhook MCP tool — webhooks are site-scoped, so any member of a site can manage them without being an admin.

Signing a request

The raw body is read verbatim and verified before anything else runs. Two headers are required:

  • X-Fastsite-Signature: t=<unix>,v1=<hmac_sha256("<t>.<body>", secret)> — the signed string is the timestamp, a literal dot, and the exact bytes you send. Compared in constant time, within a ±300 second window.
  • X-Delivery-Id — unique per delivery. It is the replay guard: a repeated id is accepted with a 200 and quietly ignored rather than processed twice.

Sign the bytes you actually transmit. Re-serialising the JSON between signing and sending is the usual cause of a signature that will not verify.

A worked example

# body.json is the exact bytes you sign and send
SECRET="whsec_…"
BODY=$(cat body.json)
TS=$(date +%s)
SIG=$(printf '%s.%s' "$TS" "$BODY" | \
  openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')

curl -X POST https://your-host/webhooks/newsroom \
  -H "X-Fastsite-Signature: t=$TS,v1=$SIG" \
  -H "X-Delivery-Id: $(uuidgen)" \
  -H "Content-Type: application/json" \
  --data-binary @body.json

What comes back

CodeMeaning
202Accepted. Recorded and queued.
200Duplicate delivery id — already seen, nothing done.
400Missing X-Delivery-Id.
401Missing or invalid signature.
404No such integration, or it is disabled.

The endpoint answers in milliseconds because it only records and enqueues. The mapping, the build, and the publish happen on the queue afterwards, with retries and backoff.

Webhooks publish; the API does not

This is the one real difference between the two ingest paths. Creating content over REST or MCP stores it and waits for you to publish. A webhook delivery runs its mapper and then chains a build and a publish automatically — an upstream system that pushes an article expects it to be live, not staged.

Built-in mappers

Mappers are code, not configuration strings. Each takes a generic payload and turns it into an engine action.

MapperPayloadDoes
autoseo{id, title, body_html, slug?, excerpt?, featured_image_url?, author?, seo_title?, seo_description?, status?, published_at?}Idempotent article ingest, keyed on id.
create_post{title, body_html, slug?, excerpt?, status?, author?, source_ref?}Maps a payload to a post.
create_pageSame as aboveMaps a payload to a page.
import_media{url, alt?, decorative?}Pulls a file by URL (SSRF-guarded) into the library.
upsert_component{handle, type, source_code, css?}Creates or updates a component.

autoseo is the one to reach for with an article pipeline. It is idempotent on the payload's id, so redelivering updates the existing post instead of creating a second one. It also adopts images: the featured image and every external <img> in the body are fetched into your media library and rewritten to local references, with alt text derived from the title when the source has none. If an image cannot be fetched, that image is dropped and the article still publishes — a broken CDN upstream should not cost you the story.

On the content mappers, status defaults to published. Pass source_ref if you want redelivery to update rather than duplicate.

When something goes wrong

Every delivery is recorded with its headers, body, and outcome, so a failure is inspectable rather than lost.

php bin/fastsite webhook:list
php bin/fastsite webhook:deliveries --limit=20
php bin/fastsite webhook:replay <delivery-id>

A payload the mapper cannot use — a missing id, no title, malformed JSON — is marked skipped and not retried, because retrying will not fix it. Anything else that throws is marked failed and retried with backoff. Replay re-runs a stored delivery from its recorded bytes, so you can fix a mapper or a credential and reprocess without asking the sender to try again.

Secrets are stored encrypted and can be revealed again from the dashboard to configure a sender. Prefer to pull instead of being pushed to? The REST API and the MCP server cover the same operations.