Skip to content

Webhooks

Theodor Dimitriou edited this page Sep 24, 2026 · 2 revisions

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.

How it works

  1. An admin registers a URL and a list of events, in Admin → Webhooks or with POST /api/webhooks.
  2. AstroBaaS generates a signing secret and shows it once.
  3. When a matching event happens, AstroBaaS sends a POST with a JSON body to the URL, signed with that secret.
  4. 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.

Registering a webhook

In the admin

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.

Through the API

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.

Events

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 the post.* events. Check data.kind if 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.created and subscriber.* 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.unsubscribed exists 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.

Wildcards

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.

The request

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" }.

Verifying the signature

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.

Node, without dependencies

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

With the typed client

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 });

Retries and the delivery log

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.

Safety rules

Private targets are refused

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: or https:, 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.

Staging copies stay silent

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.

Recipe: rebuild a static site on publish

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.

Option 1: point the webhook at the build hook

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 use content.* or *: they include content.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.

Option 2: a small relay that filters and debounces

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.updated is sent.

For those, add a scheduled rebuild (once a night, say), or poll the change feed below.

Polling instead: the change feed

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.

Troubleshooting

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.

See also

Clone this wiki locally