Repository navigation
Webhooks
How AstroBaaS tells other systems that something changed: which events it sends, how deliveries are signed and retried, how to verify them, and how to use them to rebuild a static site. This page is for developers writing a receiver and for operators registering one.
AstroBaaS sends webhooks out. Incoming payment-provider webhooks are a different feature, covered in Payments.
- An admin registers a URL and a list of events, in Admin → Webhooks or
with
POST /api/webhooks. - AstroBaaS generates a signing secret and shows it once.
- When a matching event happens, AstroBaaS sends a
POSTwith a JSON body to the URL, signed with that secret. - A delivery that fails is retried with backoff. Every attempt is recorded in a delivery log, and any delivery can be re-sent by hand.
Delivery never blocks the request that caused it. The write completes and its answer goes back to the caller; the webhook goes out in the background.
Admin → Webhooks (admins only) has a form with the target URL and a
comma-separated list of events, for example post.*, content.created. After
you press Register, the secret appears once with a copy button. Store it
in your receiver's configuration straight away.
The same screen lists registered webhooks (with a delete button) and the delivery log, where you can view a delivery's payload and redeliver it.
curl -X POST https://cms.example.com/api/webhooks \
-H "Authorization: Bearer $ADMIN_KEY" \
-H 'Content-Type: application/json' \
-d '{"url":"https://hooks.example.com/astrobaas","events":["post.*","content.created"]}'{
"success": true,
"data": {
"id": "…",
"url": "https://hooks.example.com/astrobaas",
"events": ["post.*", "content.created"],
"active": true,
"secret": "k3l…"
},
"message": "Webhook registered — copy the secret now; it will not be shown again."
}| Method and path | Purpose |
|---|---|
GET /api/webhooks |
List webhooks. The secret is never included. |
POST /api/webhooks |
Register. Body: url (http or https, up to 500 characters), events (1 to 50 strings), optional active (default true). |
DELETE /api/webhooks/{id} |
Remove. |
GET /api/webhooks/deliveries |
The delivery log, newest first. ?webhook=<id> filters, ?limit= caps (default 100, max 500). |
POST /api/webhooks/deliveries/{id}/redeliver |
Re-send a recorded delivery. |
All of these are admin only. With the typed client they are
baas.webhooks.list(), register(), remove(), deliveries() and
redeliver().
A webhook cannot be edited. To change its URL or events, register a new one and delete the old one. Registration and removal are written to the audit log.
These are the events the core sends today.
| Event | When | data |
|---|---|---|
post.created |
A post or page is created (including a duplicate) | The post |
post.updated |
A post or page is saved, including when the scheduler publishes a scheduled post | The post after the change |
post.deleted |
A post or page is deleted | { id, slug, title } |
content.created |
Staff create an entry in a custom content type | { type, entity } |
content.updated |
An entry is updated | { type, entity } |
content.deleted |
An entry is deleted | { type, id } |
content.submitted |
A visitor submits a public form | { type, entity } |
product.created |
A product is created | The product |
product.updated |
A product is updated | The product |
product.deleted |
A product is deleted | { id } |
order.created |
An order is placed | The order |
order.status_changed |
An order's fulfilment status changes | { id, previous, status } |
customer.created |
Checkout creates a customer record for a new buyer | The customer |
subscriber.confirmed |
A newsletter signup clicks the double opt-in link | { email, confirmed_at } |
subscriber.unsubscribed |
A subscriber leaves the list, or staff remove them | { email, unsubscribed_at } |
Notes:
- Pages are posts with
kind: "page", so they use thepost.*events. Checkdata.kindif you need to tell them apart. - There is no event for categories, taxonomies, media, settings or theme changes. If your integration depends on those, poll the change feed as well.
-
content.submitted,order.*,customer.createdandsubscriber.*carry personal data such as names, email addresses and addresses. Send them only to receivers you control, over HTTPS. - There is deliberately no event for an unconfirmed newsletter signup. Only a
confirmed address may be forwarded to a mailing service, and
subscriber.unsubscribedexists so the service hears about every unsubscribe. - Plugins can declare webhook subscriptions of their own. Those only fire while the plugin is active, and they are removed when the plugin is uninstalled.
Each entry in events is matched against the event name:
| Pattern | Matches |
|---|---|
post.created |
exactly that event |
post.* |
every event that starts with post.
|
* |
every event |
Be careful with content.* and *: they include content.submitted, which
fires for every public form submission. List the exact events you need when
that matters.
Every delivery is a POST to the registered URL:
POST /astrobaas HTTP/1.1
Content-Type: application/json
User-Agent: AstroBaaS-Webhook/1
X-AstroBaaS-Event: post.updated
X-AstroBaaS-Timestamp: 1790000000000
X-AstroBaaS-Signature: sha256=5d41402abc4b2a76b9719d911017c592…
{"event":"post.updated","timestamp":"1790000000000","data":{ … }}| Header | Meaning |
|---|---|
X-AstroBaaS-Event |
The event name, the same as event in the body. |
X-AstroBaaS-Timestamp |
When the event fired, in milliseconds since the Unix epoch. The same value as timestamp in the body. Retries and redeliveries keep the original value. |
X-AstroBaaS-Signature |
sha256= followed by the hex HMAC-SHA256 of the raw body, keyed with the webhook's secret. |
The body is always { "event", "timestamp", "data" }.
Recompute the HMAC over the exact bytes you received and compare in constant time. Do not parse and re-serialise the JSON first: any change in whitespace or key order breaks the signature.
// server.mjs: node server.mjs (Node 22)
import http from 'node:http';
import crypto from 'node:crypto';
const SECRET = process.env.ASTROBAAS_WEBHOOK_SECRET;
const MAX_AGE_MS = 30 * 60 * 1000; // longer than the default retry schedule (about 13 min)
function validSignature(rawBody, header) {
if (!header || !header.startsWith('sha256=')) return false;
const expected = crypto.createHmac('sha256', SECRET).update(rawBody).digest();
const given = Buffer.from(header.slice(7), 'hex');
return given.length === expected.length && crypto.timingSafeEqual(given, expected);
}
http.createServer((req, res) => {
const chunks = [];
req.on('data', (c) => chunks.push(c));
req.on('end', () => {
const raw = Buffer.concat(chunks);
if (!validSignature(raw, req.headers['x-astrobaas-signature'])) {
res.writeHead(401).end();
return;
}
const payload = JSON.parse(raw.toString('utf8'));
// Optional: reject old deliveries. The timestamp is inside the signed body.
if (Date.now() - Number(payload.timestamp) > MAX_AGE_MS) {
res.writeHead(400).end();
return;
}
console.log('verified', payload.event);
res.writeHead(204).end(); // answer quickly; do slow work afterwards
});
}).listen(8787);Read the timestamp from the body rather than trusting the header on its own: the body is what the signature covers. Retries and manual redeliveries keep the original timestamp, so a maximum age must be longer than your retry schedule, and an old event re-sent from the admin will be rejected by it.
astrobaas/client exports the same check for any runtime with WebCrypto
(Node, Deno, edge functions, workers):
import { verifyWebhookSignature } from 'astrobaas/client';
const raw = await request.text(); // the raw body
const ok = await verifyWebhookSignature(
process.env.ASTROBAAS_WEBHOOK_SECRET!,
raw,
request.headers.get('x-astrobaas-signature'),
);
if (!ok) return new Response(null, { status: 401 });A delivery counts as successful when the receiver answers with any 2xx
status within 5 seconds. Anything else (another status, a timeout, a
connection error) is a failure and is retried.
| Attempt | When |
|---|---|
| 1 | immediately |
| 2 | 30 seconds after attempt 1 fails |
| 3 | 2 minutes after attempt 2 fails |
| 4 | 10 minutes after attempt 3 fails |
Change the schedule with WEBHOOK_RETRY_DELAYS_MS, a comma- or
space-separated list of delays in milliseconds. The number of entries is the
number of retries:
WEBHOOK_RETRY_DELAYS_MS="10000,60000,300000,1800000"Each delivery in the log records the webhook, URL, event, the exact payload
that was signed, its status (pending, success or failed), the number
of attempts, and the last HTTP status and error. On the default JSON store
the log keeps the most recent 1000 deliveries.
Retries do not survive a restart. They run on in-process timers, so a
deploy or restart drops any retry that was still waiting, and its delivery
stays pending or failed. Recover it with Redeliver in the admin or
POST /api/webhooks/deliveries/{id}/redeliver. A redelivery sends the stored
payload, with its original timestamp and a valid signature, as a new entry in
the log with its own retries.
Your receiver should be idempotent. The same event can arrive more than once, through a retry after a timeout the receiver actually processed, or through a manual redelivery.
To stop the server being used to probe its own network, registration refuses URLs whose host is obviously internal:
-
localhost,*.localhost,*.local,*.internal,*.home.arpa - loopback, private and link-local IPv4 (
127.0.0.0/8,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,169.254.0.0/16,100.64.0.0/10,0.0.0.0/8) - IPv6 loopback, link-local (
fe80::/10) and unique-local (fc00::/7) addresses, and IPv4-mapped forms of the above - anything that is not
http:orhttps:, and URLs with a username or password in them
For local development and tests, set WEBHOOK_ALLOW_PRIVATE=1 to allow these
targets. Do not set it in production.
The check looks at the URL as written. It does not resolve DNS, so a public name that resolves to a private address is not caught. The check runs when a webhook is registered, not again on each delivery. See Security.
A staging deployment (STAGING=1 or ASTROBAAS_STAGING=1) sends no webhooks
at all, and redelivery is refused there too. A staging copy carries
production's webhook registrations as data, and without this rule a test order
would reach your real fulfilment partner. See
Staging and maintenance.
Static hosts such as Netlify, Vercel and Cloudflare Pages give each project a
build hook: a secret URL that starts a new build when it receives a
POST. You can register that URL directly as an AstroBaaS webhook.
curl -X POST https://cms.example.com/api/webhooks \
-H "Authorization: Bearer $ADMIN_KEY" -H 'Content-Type: application/json' \
-d '{
"url": "https://api.netlify.com/build_hooks/…",
"events": ["post.*", "content.created", "content.updated", "content.deleted"]
}'- List the
content.*events you want one by one. Do not usecontent.*or*: they includecontent.submitted, so every contact form or enquiry a visitor sent would start a build. - Add
product.*if your static site renders the catalogue. - The build hook ignores the body and cannot check the signature. That is acceptable because the hook URL is itself the secret, but keep it out of anything public.
The drawback: every save starts a build, including saving a draft, because a
draft save is a post.updated too.
A relay verifies the signature, ignores changes that cannot affect the public site, and collapses a burst of edits into one build.
// relay.mjs: node relay.mjs
import http from 'node:http';
import crypto from 'node:crypto';
const SECRET = process.env.ASTROBAAS_WEBHOOK_SECRET;
const BUILD_HOOK = process.env.BUILD_HOOK_URL;
const QUIET_MS = 60_000; // wait for a minute of quiet before building
let timer = null;
function valid(raw, header) {
if (!header?.startsWith('sha256=')) return false;
const want = crypto.createHmac('sha256', SECRET).update(raw).digest();
const got = Buffer.from(header.slice(7), 'hex');
return got.length === want.length && crypto.timingSafeEqual(got, want);
}
function affectsPublicSite({ event, data }) {
if (event === 'content.submitted') return false; // visitor form posts
if (event === 'post.created') return data?.status === 'published';
// post.updated: a publish, an edit to a live post, or an unpublish all matter,
// and the payload does not say what the status was before. Rebuild on all of
// them unless you track published slugs yourself.
return true;
}
http.createServer((req, res) => {
const chunks = [];
req.on('data', (c) => chunks.push(c));
req.on('end', () => {
const raw = Buffer.concat(chunks);
if (!valid(raw, req.headers['x-astrobaas-signature'])) return res.writeHead(401).end();
const payload = JSON.parse(raw.toString('utf8'));
res.writeHead(204).end(); // answer within 5 s
if (!affectsPublicSite(payload)) return;
clearTimeout(timer);
timer = setTimeout(() => {
fetch(BUILD_HOOK, { method: 'POST' }).catch((e) => console.error('build hook failed', e));
}, QUIET_MS);
});
}).listen(8787);Register the relay's public HTTPS URL with the events you care about (for
example ["post.*", "content.created", "content.updated", "content.deleted", "product.*"]).
Things that do not send an event, so a rebuild will not happen by itself:
- editing categories, taxonomies, site settings, the theme, or media metadata;
- nothing is missed for scheduled posts, but note the timing: the scheduler
publishes them on its next sweep (every minute by default, and once at
start-up), and that is when
post.updatedis sent.
For those, add a scheduled rebuild (once a night, say), or poll the change feed below.
If you cannot run an always-on receiver, poll GET /api/content/changes. It is
public, returns metadata only (type, id, action, timestamp and, for updates,
the changed field names), and covers types an anonymous caller could read
anyway. At most 1000 changes are retained; meta.truncated tells you when
retention dropped something inside your window, and then you should rebuild
everything.
curl 'https://cms.example.com/api/content/changes?since=2026-09-20T10:00:00Z&limit=200'The paging rules and a complete revalidation example are in INTEGRATION.md §5.
| Symptom | Check |
|---|---|
Registration fails with a url validation error |
The host looks private. Use a public URL, or WEBHOOK_ALLOW_PRIVATE=1 in development. |
| Nothing arrives | Is the deployment a staging copy? Does an event name in the list match (post.*, not posts.*)? Is the webhook active? |
Deliveries show failed with no status |
The receiver did not answer within 5 seconds, or the connection failed. Answer first, work afterwards. |
| Signature never matches | You are hashing parsed-and-re-serialised JSON, or the wrong secret. Hash the raw bytes. |
Deliveries stuck at pending after a deploy |
Retries were lost on restart. Redeliver them. |
- Headless and REST API: authentication and the rest of the API
-
SDK, CLI and MCP:
baas.webhooks.*andverifyWebhookSignature - Security: the SSRF guard and its limits
- Staging and maintenance
- Payments: incoming webhooks from payment providers
- src/lib/webhooks.ts and src/lib/webhook-util.ts
- INTEGRATION.md §5
AstroBaaS is free software under GPL-3.0-or-later. This wiki describes the latest release; the repository and its CHANGELOG are the final word. Found a mistake here? Open an issue.
Getting started
Using the CMS
Building with it
Extending
Commerce and email
Operating
Project