Docs

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 replies 200 first and does the work afterwards.
  • Handle retries. A retry carries the same id. The seen set skips repeats (use a small database or file if the receiver restarts).
  • The payload has collection and slug, 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.

Keep the secret out of the repo. Read it from the environment, never commit it. Anyone with the signing secret can forge events to your receiver; anyone who can reach an unsigned receiver can too — always verify the signature before acting.

Last updated Edit this page on GitHub ↗