CapyDB Docs
Guides

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

EventFires when
job.completedAny asynchronous job reaches completed
job.failedAny asynchronous job reaches failed
project.readyA project finishes provisioning
project.deletedA project deletion completes
backup.completedA backup job completes
preview.readyA preview database becomes ready
preview.deletedA preview database is deleted (manual or TTL)
import.completedAn import finishes
restore.completedA restore finishes
credentials.rotatedProject credentials are rotated
alert.triggeredA usage threshold alert opens, escalates to critical, or re-notifies (at most every 24h)
alert.resolvedA 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 type
  • X-CapyDB-Delivery — unique delivery id (use it for idempotency)
  • X-CapyDB-Signature — see below
  • User-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:

verify-capydb-webhook.ts
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.