webhooks
Spirby pushes events to your endpoint as signed http posts. Your receiver returns a 2xx; spirby retries on anything else, on a tight schedule, until it succeeds or a budget is exhausted.
register a webhook
Section titled “register a webhook”Webhooks live on the same /v1/* surface as the rest of the api. Registration requires a read:write key on a paid plan.
curl -s -X POST "$SPIRBY_BASE/v1/webhooks" \ -H "Authorization: Bearer $SPIRBY_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://your-app.example.com/spirby-webhook", "events": ["post.created", "comment.created", "changelog.published"] }' | jqThe response includes a one-time secret (whsec_...). Save it now — subsequent reads omit the secret. If you lose it, delete the webhook and register a new one.
You can also manage webhooks visually under settings → webhooks in the admin app.
events
Section titled “events”| event | when |
|---|---|
post.created | a new post lands on a board |
post.status_changed | a post moves between statuses |
vote.created | someone votes on a post |
comment.created | a top-level comment or reply is posted |
changelog.published | a changelog entry flips from draft to published |
A webhook subscribes to one or more events; only matching events fire deliveries.
delivery shape
Section titled “delivery shape”Spirby posts json to your url with these headers:
| header | value |
|---|---|
Content-Type | application/json |
User-Agent | Spirby-Webhooks/1.0 |
X-Spirby-Event | the event type (e.g. post.created) |
X-Spirby-Delivery | a unique id for this delivery — use it to dedupe |
X-Spirby-Signature | t=<unix>,v1=<sha256-hex> — see verifying |
Spirby refuses redirects (a 302 is a hard failure) so a public allowlisted host can’t 302 to an internal ip.
example payload — post.created
Section titled “example payload — post.created”{ "event": "post.created", "organizationId": "01H...", "boardId": "01H...", "post": { "id": "01H...", "title": "Add csv export", "slug": "add-csv-export", "status": "open", "createdAt": "2026-05-07T12:00:00.000Z" }}example payload — post.status_changed
Section titled “example payload — post.status_changed”{ "event": "post.status_changed", "organizationId": "01H...", "boardId": "01H...", "post": { "id": "01H...", "title": "...", "slug": "...", "status": "in_progress", "createdAt": "..." }, "previousStatus": "open"}example payload — vote.created
Section titled “example payload — vote.created”{ "event": "vote.created", "organizationId": "01H...", "boardId": "01H...", "vote": { "id": "01H...", "postId": "01H...", "userId": "01H...", "createdAt": "2026-05-07T12:00:00.000Z" }}userId is null for anonymous voters (email-verified flow on the public board).
example payload — comment.created
Section titled “example payload — comment.created”{ "event": "comment.created", "organizationId": "01H...", "boardId": "01H...", "comment": { "id": "01H...", "postId": "01H...", "parentId": null, "authorUserId": "01H...", "bodyText": "Sounds great. Counting on this for q2.", "createdAt": "2026-05-07T12:00:00.000Z" }}example payload — changelog.published
Section titled “example payload — changelog.published”{ "event": "changelog.published", "organizationId": "01H...", "boardId": "01H...", "entry": { "id": "01H...", "title": "April update", "slug": "april-update", "publishedAt": "2026-05-07T12:00:00.000Z" }}verifying signatures
Section titled “verifying signatures”The signature header is t=<unix-seconds>,v1=<hmac-sha256-hex>. The hex is HMAC-SHA256(secret, "<unix>.<rawBody>"), where rawBody is the unparsed request body.
Always verify against the raw bytes. A json-roundtrip-and-stringify won’t match because key order, whitespace, and unicode escapes are not stable.
import { createHmac, timingSafeEqual } from 'node:crypto'
export function verifySpirbyWebhook( rawBody: string, signatureHeader: string, secret: string,): boolean { const m = /^t=(\d+),v1=([a-f0-9]+)$/.exec(signatureHeader) if (!m) return false const [, ts, sig] = m
// 5-minute freshness window — discourages naive replay even if the // attacker captured a valid (ts, signature) pair off the wire. if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false
const expected = createHmac('sha256', secret).update(`${ts}.${rawBody}`).digest('hex') const a = Buffer.from(sig, 'hex') const b = Buffer.from(expected, 'hex') return a.length === b.length && timingSafeEqual(a, b)}In an express handler, parse the raw body before json:
import express from 'express'
const app = express()app.post( '/spirby-webhook', express.raw({ type: 'application/json' }), (req, res) => { const ok = verifySpirbyWebhook( req.body.toString('utf8'), req.header('x-spirby-signature') ?? '', process.env.SPIRBY_WEBHOOK_SECRET as string, ) if (!ok) return res.status(400).end() const payload = JSON.parse(req.body.toString('utf8')) // ... handle payload res.status(200).end() },)python
Section titled “python”import hmac, hashlib, re, time
def verify_spirby_webhook(raw_body: bytes, signature_header: str, secret: str) -> bool: m = re.fullmatch(r"t=(\d+),v1=([0-9a-f]+)", signature_header) if not m: return False ts, sig = m.group(1), m.group(2) if abs(time.time() - int(ts)) > 300: return False expected = hmac.new( secret.encode("utf-8"), f"{ts}.".encode("utf-8") + raw_body, hashlib.sha256, ).hexdigest() return hmac.compare_digest(sig, expected)In a flask handler:
from flask import Flask, request, abortimport os
app = Flask(__name__)
@app.post("/spirby-webhook")def spirby_webhook(): raw = request.get_data() # raw bytes, before json parse if not verify_spirby_webhook( raw, request.headers.get("X-Spirby-Signature", ""), os.environ["SPIRBY_WEBHOOK_SECRET"], ): abort(400) # ... handle request.get_json() return ("", 200)retries
Section titled “retries”A delivery succeeds on any 2xx status. Anything else triggers a retry on this schedule:
| attempt | wait before fire |
|---|---|
| 1 | immediate |
| 2 | 30s |
| 3 | 2m |
| 4 | 10m |
| 5 | 1h |
After attempt 5 fails the delivery is terminal — it appears in the deliveries log with failedAt set. A 4xx is treated like any other failure for retry purposes; spirby has no way to tell whether your 400 is permanent or a momentary bug.
If a webhook racks up 50 consecutive failures across deliveries, spirby disables it and emails the org admins. Re-enable from the admin ui or via PATCH /v1/webhooks/{id} with {"enabled": true}. Re-enabling resets the counter.
replaying a delivery
Section titled “replaying a delivery”You can re-fire any past delivery — useful when your handler had a bug at the time, or you accidentally returned 200 and dropped the event. Replays insert a fresh delivery row with the original payload and a new X-Spirby-Delivery id.
curl -s -X POST \ "$SPIRBY_BASE/v1/webhooks/$WEBHOOK_ID/deliveries/$DELIVERY_ID/replay" \ -H "Authorization: Bearer $SPIRBY_KEY" | jqYou can also replay from settings → webhooks in the admin ui.
A replay is a brand-new delivery row with its own X-Spirby-Delivery id, so receivers de-duping by that header treat it as a distinct event and process it. Spirby refuses replays for webhooks that are disabled or no longer subscribe to the event type.
idempotency
Section titled “idempotency”Spirby’s at-least-once delivery means duplicates are possible — a network partition mid-response can leave you handling the same event twice. Your receiver should treat X-Spirby-Delivery as the dedupe key and store recently-seen ids. A simple INSERT ... ON CONFLICT DO NOTHING against a (delivery_id) unique index is enough for most receivers.