Webhooks
Org-level outbound webhooks for lifecycle events, signed with HMAC-SHA256 and retried with backoff.
Endpoints
Webhook endpoints belong to the organization. Each endpoint has an HTTPS receiver URL, an optional description, and a list of subscribed event types (empty list = all events). Endpoints can be deactivated without being deleted, and the delivery history per endpoint is queryable from the dashboard or GET .../webhook-endpoints/{id}/deliveries.
Receiver URLs must be https:// and publicly reachable — private and internal addresses are rejected, same SSRF posture as import sources.
Event catalog
| Event | Fires when |
|---|---|
job.completed | Any asynchronous job reaches completed |
job.failed | Any asynchronous job reaches failed |
project.ready | A project finishes provisioning |
project.deleted | A project deletion completes |
backup.completed | A backup job completes |
preview.ready | A preview database becomes ready |
preview.deleted | A preview database is deleted (manual or TTL) |
import.completed | An import finishes |
restore.completed | A restore finishes |
credentials.rotated | Project credentials are rotated |
alert.triggered | A usage threshold alert opens, escalates to critical, or re-notifies (at most every 24h) |
alert.resolved | A usage threshold alert resolves |
Payload
Deliveries are JSON POSTs with a stable envelope:
{
"id": "evt_...",
"type": "preview.ready",
"created_at": "2026-06-10T08:00:00.000Z",
"data": { "project_id": "prj_...", "preview_id": "pvw_..." }
}Headers on every delivery:
X-CapyDB-Event— the event typeX-CapyDB-Delivery— unique delivery id (use it for idempotency)X-CapyDB-Signature— see belowUser-Agent: capydb-webhooks/1.0
Signature verification
When you create an endpoint, CapyDB returns a signing secret with the whsec_ prefix. It is shown exactly once — store it; afterwards it can only be rotated.
Each delivery carries:
X-CapyDB-Signature: t=<unix-timestamp>,v1=<hex hmac>where v1 is the hex-encoded HMAC-SHA256 of the string "<t>.<raw request body>" keyed with your whsec_ secret. Verify with a constant-time comparison and reject stale timestamps:
import { createHmac, timingSafeEqual } from 'node:crypto'
const TOLERANCE_SECONDS = 300
export function verifyCapyDBSignature(
header: string,
rawBody: string,
secret: string
): boolean {
const parts = Object.fromEntries(
header.split(',').map((pair) => pair.split('=', 2) as [string, string])
)
const timestamp = Number(parts.t)
const received = parts.v1
if (!Number.isFinite(timestamp) || !received) return false
const age = Math.abs(Date.now() / 1000 - timestamp)
if (age > TOLERANCE_SECONDS) return false
const expected = createHmac('sha256', secret)
.update(`${timestamp}.${rawBody}`)
.digest('hex')
const a = Buffer.from(expected, 'hex')
const b = Buffer.from(received, 'hex')
return a.length === b.length && timingSafeEqual(a, b)
}
// Express example — note the raw body, not the parsed JSON:
// app.post('/webhooks/capydb', express.raw({ type: 'application/json' }), (req, res) => {
// const ok = verifyCapyDBSignature(
// req.header('X-CapyDB-Signature') ?? '',
// req.body.toString('utf8'),
// process.env.CAPYDB_WEBHOOK_SECRET!
// )
// if (!ok) return res.status(401).end()
// const event = JSON.parse(req.body.toString('utf8'))
// // handle event.type ...
// res.status(204).end()
// })Sign the raw body exactly as received. Re-serializing parsed JSON is the classic way to get a mismatch.
Retries
A delivery is considered successful on any 2xx response. Anything else (including timeouts) is retried with exponential backoff — 30s, 1m, 2m, 4m, 8m, 16m, then capped at 30m — for up to 8 attempts total, after which the delivery is marked failed. Failed deliveries stay visible in the delivery history.
Respond fast (ideally 204 immediately, then process async). Slow receivers burn their own retry budget.
Secret rotation
POST .../webhook-endpoints/{id}/rotate-secret issues a new whsec_ secret, returned once. Deliveries signed with the old secret stop immediately, so deploy the new secret to your receiver first if you can tolerate a short verification gap, or accept both during the rollover.