Skip to content

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.

Webhooks live on the same /v1/* surface as the rest of the api. Registration requires a read:write key on a paid plan.

Terminal window
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"]
}' | jq

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

eventwhen
post.createda new post lands on a board
post.status_changeda post moves between statuses
vote.createdsomeone votes on a post
comment.createda top-level comment or reply is posted
changelog.publisheda changelog entry flips from draft to published

A webhook subscribes to one or more events; only matching events fire deliveries.

Spirby posts json to your url with these headers:

headervalue
Content-Typeapplication/json
User-AgentSpirby-Webhooks/1.0
X-Spirby-Eventthe event type (e.g. post.created)
X-Spirby-Deliverya unique id for this delivery — use it to dedupe
X-Spirby-Signaturet=<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.

{
"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"
}
}
{
"event": "post.status_changed",
"organizationId": "01H...",
"boardId": "01H...",
"post": { "id": "01H...", "title": "...", "slug": "...", "status": "in_progress", "createdAt": "..." },
"previousStatus": "open"
}
{
"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).

{
"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"
}
}
{
"event": "changelog.published",
"organizationId": "01H...",
"boardId": "01H...",
"entry": {
"id": "01H...",
"title": "April update",
"slug": "april-update",
"publishedAt": "2026-05-07T12:00:00.000Z"
}
}

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()
},
)
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, abort
import 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)

A delivery succeeds on any 2xx status. Anything else triggers a retry on this schedule:

attemptwait before fire
1immediate
230s
32m
410m
51h

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.

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.

Terminal window
curl -s -X POST \
"$SPIRBY_BASE/v1/webhooks/$WEBHOOK_ID/deliveries/$DELIVERY_ID/replay" \
-H "Authorization: Bearer $SPIRBY_KEY" | jq

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

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.