Webhook recipes
Small receivers that react to publishing: announce a post, ping search engines, clear a CDN cache, notify a reviewer. They run on your own machine or server — Orbiter only sends the signed event.
Read Webhooks first for the payload, headers and how the signature works. The receiver below verifies it and calls one handler per event; the recipes after it are the handlers.
The receiver
// orbiter-hooks.mjs — receives Orbiter webhooks, verifies the signature, runs small actions.
// Run: ORBITER_WEBHOOK_SECRET=whsec_… SITE_URL=https://example.com node orbiter-hooks.mjs
import { createServer } from 'node:http';
import { createHmac, timingSafeEqual } from 'node:crypto';
const SECRET = process.env.ORBITER_WEBHOOK_SECRET;
const seen = new Set(); // X-Orbiter-Delivery ids we already handled (retries!)
function verify(raw, headers) {
const ts = headers['x-orbiter-timestamp'];
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false; // replay window: 5 min
const expected = 'sha256=' + createHmac('sha256', SECRET).update(`${ts}.${raw}`).digest('hex');
const given = String(headers['x-orbiter-signature'] ?? '');
return given.length === expected.length && timingSafeEqual(Buffer.from(given), Buffer.from(expected));
}
const handlers = {
async publish(e) { /* see the recipes below */ },
async review(e) { /* see the recipes below */ },
};
createServer((req, res) => {
let raw = '';
req.on('data', (d) => { raw += d; if (raw.length > 100_000) req.destroy(); });
req.on('end', () => {
if (req.method !== 'POST' || !verify(raw, req.headers)) { res.writeHead(401).end(); return; }
const event = JSON.parse(raw);
res.writeHead(200).end(); // answer fast; Orbiter times out after 10 s
if (seen.has(event.id)) return; // retry of something we already did
seen.add(event.id);
handlers[event.event]?.(event).catch((err) => console.error(event.event, err.message));
});
}).listen(8787);
- Answer fast. Orbiter waits 10 seconds and retries on anything but
2xx. The receiver replies200first and does the work afterwards. - Handle retries. A retry carries the same
id. Theseenset skips repeats (use a small database or file if the receiver restarts). - The payload has
collectionandslug, not a URL — build the page address from your own routes (SITE_URL+ path). - Put the receiver behind HTTPS and add its URL under Settings → Webhooks; use Test to send a
ping(ignored by the handlers above).
Notify a reviewer — Slack or Telegram
Event review, fired when an editor submits an entry (review workflow).
// Slack: someone submitted an entry for review
async review(e) {
await fetch(process.env.SLACK_WEBHOOK_URL, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: `Review requested: ${e.collection}/${e.slug}` }),
});
},
// Telegram
async review(e) {
await fetch(`https://api.telegram.org/bot${process.env.TG_TOKEN}/sendMessage`, {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ chat_id: process.env.TG_CHAT, text: `Review requested: ${e.collection}/${e.slug}` }),
});
},Tell search engines — IndexNow
Event publish. IndexNow is supported by Bing and others; you prove ownership by serving your key as a text file.
// IndexNow: tell Bing & co. a page changed (host a file at https://example.com/<KEY>.txt containing KEY)
async publish(e) {
const site = new URL(process.env.SITE_URL);
await fetch('https://api.indexnow.org/indexnow', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
host: site.host, key: process.env.INDEXNOW_KEY,
keyLocation: `${site.origin}/${process.env.INDEXNOW_KEY}.txt`,
urlList: [`${site.origin}/${e.collection}/${e.slug}`], // adapt to your routes
}),
});
},Clear a CDN cache — Cloudflare
// Cloudflare: purge the changed page from the cache
async publish(e) {
await fetch(`https://api.cloudflare.com/client/v4/zones/${process.env.CF_ZONE}/purge_cache`, {
method: 'POST',
headers: { Authorization: `Bearer ${process.env.CF_TOKEN}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ files: [`${process.env.SITE_URL}/${e.collection}/${e.slug}`] }),
});
},Announce a post — Mastodon
// Mastodon: announce a new post (token needs the write:statuses scope)
async publish(e) {
await fetch(`https://${process.env.MASTODON_HOST}/api/v1/statuses`, {
method: 'POST',
headers: { Authorization: `Bearer ${process.env.MASTODON_TOKEN}`, 'Idempotency-Key': e.id },
body: new URLSearchParams({ status: `New: ${process.env.SITE_URL}/${e.collection}/${e.slug}` }),
});
},For other networks or a scheduling tool, call its CLI or API from the same publish handler. Use the delivery id as an idempotency key where the target supports one, so a retry can't post twice.
Rebuild a search index or the site
In the publish handler run your build step, for example execFile('npx', ['pagefind', '--site', 'dist']), or call a deploy hook. For a plain rebuild you don't need a receiver at all: the build webhook already triggers it.