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
contact.createda new contact is created (rest, mcp, admin, or identify-when-created)
contact.updateda trait field on an existing contact changes — lastSeenAt-only updates are suppressed
contact.deleteda contact is deleted; attached posts survive with contactId set to null

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

contact.updated includes a changes field — a Record<string, { from, to }> diff so subscribers can apply targeted updates without re-fetching the row. Identify calls that only bump lastSeenAt (no trait delta) skip the event entirely, so a widget identifying on every page load doesn’t flood your subscribers.

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"
}
}
{
"event": "contact.created",
"organizationId": "01H...",
"contact": {
"id": "ctc_01H...",
"externalUserId": "usr_42",
"email": "[email protected]",
"name": "Acme Corp",
"plan": "pro",
"mrrCents": 14900,
"currency": "USD",
"source": "identify",
"consentBasis": "sdk_identify",
"metadata": { "signupSource": "google-ads" },
"firstSeenAt": "2026-05-14T12:00:00.000Z",
"lastSeenAt": "2026-05-14T12:00:00.000Z",
"createdAt": "2026-05-14T12:00:00.000Z",
"updatedAt": "2026-05-14T12:00:00.000Z"
}
}

Fires on every contact insert path — POST /v1/contacts, POST /v1/contacts/identify when no existing contact matched externalUserId, MCP create_contact / identify_contact, admin-ui creates, AND public-board interactions where the end-user opted in (source will be public_post / public_vote / public_comment in those cases, consentBasis will be public_interaction_opt_in).

source and consentBasis are open enums. Future values may be added in minor releases. Consumers must treat unknown values as additive — log and fall through, do not fail closed. See contacts → provenance and consent for the current value matrix.

{
"event": "contact.updated",
"organizationId": "01H...",
"contact": {
"id": "ctc_01H...",
"externalUserId": "usr_42",
"email": "[email protected]",
"name": "Acme Corp",
"plan": "enterprise",
"mrrCents": 49900,
"currency": "USD",
"source": "identify",
"consentBasis": "sdk_identify",
"metadata": { "signupSource": "google-ads" },
"firstSeenAt": "2026-05-14T12:00:00.000Z",
"lastSeenAt": "2026-05-14T13:00:00.000Z",
"createdAt": "2026-05-14T12:00:00.000Z",
"updatedAt": "2026-05-14T13:00:00.000Z"
},
"changes": {
"plan": { "from": "pro", "to": "enterprise" },
"mrrCents": { "from": 14900, "to": 49900 }
}
}

The changes object covers trait fields only — lastSeenAt/updatedAt/firstSeenAt are never included even when they advance. No webhook fires when the only delta is lastSeenAt, so a widget calling identify on every page-load doesn’t flood your subscribers.

{
"event": "contact.deleted",
"organizationId": "01H...",
"contact": {
"id": "ctc_01H...",
"externalUserId": "usr_42"
}
}

The payload is intentionally minimal — the contact row is gone, so all that’s left is the id pair. Attached posts survive with contactId cleared to null; receive post.created / post.status_changed for those posts continues as normal.

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.