Send inserts, updates, deletes and Auth events to your own HTTPS endpoint: signed, retried for 24 hours, logged with the response, and replayable.
A webhook sends an HTTPS POST to your endpoint each time a row in a chosen table is inserted, updated or deleted, or a user signs up, confirms, signs in, changes or is deleted. You don't write triggers or functions, and a slow or failing endpoint never slows down your database: events are queued and delivered in the background.
Create one in Studio under Database → Webhooks: a name, the endpoint URL, the events and, for database events, the tables. The signing secret is shown once you save; you can show it again later.
#Events
| Event | Sent when | record / old_record |
|---|---|---|
db.insert | A row was added to a chosen table | The new row / null |
db.update | A row of a chosen table changed | The row after / before the change |
db.delete | A row was deleted | null / the deleted row |
auth.user.created | A user signed up, was invited or created by an admin | The user / null |
auth.user.confirmed | A user confirmed their e-mail or phone | The user / null |
auth.user.signed_in | A user signed in, with any method | The user / null |
auth.user.updated | E-mail, phone, metadata or ban changed | The user after / before |
auth.user.deleted | A user was deleted | null / the user |
types.updated | New generated types after a schema change | { version, sha, languages, urls } / null |
budget.alert | The project crossed a budget threshold | { resource, threshold, used, budget, percent, stopped, period, test } / null |
crash.issue.new, crash.issue.regressed | A new crash issue, or a resolved one came back | { issue_id, type, message, culprit, platform, release, events, users, first_seen, url } / null |
monitor.down, monitor.failed, monitor.long, monitor.recovered | A job monitor missed a run, failed, ran too long, or is back to normal | { monitor_id, name, kind, target, alert, state, last_run_at, last_message, detail, url } / null |
health.alert, health.recovered | A health check finding is new or worse, or it cleared | { id, title, level, service, measured, baseline, threshold, next_step, url } / null |
metering.threshold | A customer crossed a usage meter alert threshold | { alert_id, alert_name, meter, meter_name, unit, customer, threshold, value, period, period_start, fired_at } / null |
The user in Auth events has id, email, phone, the confirmation and sign-in times, user_metadata, app_metadata, is_anonymous and banned_until. Password hashes and tokens are never sent. TRUNCATE sends no events, and neither do changes made while triggers are off (for example a restore).
#Payload
{
"id": "abcdefghijklmnop_4182",
"type": "db.update",
"created_at": "2026-10-25T12:00:00.000Z",
"project_ref": "abcdefghijklmnop",
"schema": "public",
"table": "orders",
"record": { "id": 42, "status": "paid", "total": 1999 },
"old_record": { "id": 42, "status": "pending", "total": 1999 }
}created_at is the time of the change, not of the delivery. If a row is larger than 256 KB, record and old_record keep only id and the payload has "truncated": true: read the row from the API instead.
#Verify the signature
Every request has these headers:
StitchDB-Webhook-Id: the event id, the same on every retry. Use it to skip events you already processed.StitchDB-Webhook-Timestamp: Unix seconds when this attempt was sent.StitchDB-Event: the event type.StitchDB-Signature:v1=<hex>, the HMAC-SHA256 of<timestamp>.<raw body>with your signing secret.
import crypto from 'node:crypto'
// rawBody: the request body exactly as received (a string or Buffer), before JSON parsing.
export function verify(secret, headers, rawBody) {
const ts = headers['stitchdb-webhook-timestamp']
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false // older than 5 minutes
const want = 'v1=' + crypto.createHmac('sha256', secret).update(ts + '.' + rawBody).digest('hex')
const got = String(headers['stitchdb-signature'] || '')
return got.length === want.length && crypto.timingSafeEqual(Buffer.from(got), Buffer.from(want))
}const enc = new TextEncoder()
Deno.serve(async (req) => {
const body = await req.text()
const ts = req.headers.get('stitchdb-webhook-timestamp') ?? ''
const key = await crypto.subtle.importKey('raw', enc.encode(Deno.env.get('WEBHOOK_SECRET')!), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'])
const mac = new Uint8Array(await crypto.subtle.sign('HMAC', key, enc.encode(ts + '.' + body)))
const want = 'v1=' + [...mac].map((b) => b.toString(16).padStart(2, '0')).join('')
if (req.headers.get('stitchdb-signature') !== want || Math.abs(Date.now() / 1000 - Number(ts)) > 300) {
return new Response('Bad signature', { status: 401 })
}
const event = JSON.parse(body)
// … handle event.type
return new Response('ok')
})import hmac, hashlib, time
def verify(secret: str, headers, raw_body: bytes) -> bool:
ts = headers.get('StitchDB-Webhook-Timestamp', '')
if abs(time.time() - int(ts or 0)) > 300:
return False
want = 'v1=' + hmac.new(secret.encode(), ts.encode() + b'.' + raw_body, hashlib.sha256).hexdigest()
return hmac.compare_digest(headers.get('StitchDB-Signature', ''), want)#Delivery and retries
- Any 2xx answer within 15 seconds counts as delivered. Answer quickly and do slow work afterwards.
- Otherwise the event is retried after 10 seconds, 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, 12 hours and 24 hours. After the last attempt it is marked Failed and kept in the log.
- Delivery is at least once: an event can arrive twice (for example after a timeout your endpoint did process). Order is not guaranteed between events; use
created_ator your own version column when order matters. - URLs must be
https://on a public address. Redirects are not followed. You can add up to 10 headers of your own (for exampleAuthorization); they are stored encrypted.
#Delivery log and replay
Open a webhook in Studio to see every delivery: the request body, the status code and the first 2 KB of your endpoint's response, how long it took, and each attempt. Replay sends one delivery again; Replay failed sends every failed delivery again, oldest first. Send test sends a test.ping event right away and shows the answer, without logging or retrying it.
#When an endpoint keeps failing
If every delivery to a webhook has failed for an hour, the project owner gets an e-mail. After 3 days of failures the webhook is turned off and the owner gets another e-mail; the events it missed stay in the log, so fix the endpoint, turn the webhook on and use Replay failed.
#Limits
| Free | Pro | Scale | Enterprise | |
|---|---|---|---|---|
| Webhooks per project | 2 | 20 | 50 | 200 |
| Events per project per month | 10,000 | 1,000,000 | 5,000,000 | 50,000,000 |
| Delivery log kept | 1 day | 7 days | 14 days | 30 days |
An event counts once however many webhooks receive it. When a project reaches its monthly limit, new events are not sent until the next month and the owner gets an e-mail; deliveries already queued are still sent.
#Management API
curl -X POST https://stitchdb.com/platform/v1/projects/<ref>/webhooks \
-H "Authorization: Bearer $STITCHDB_TOKEN" -H 'Content-Type: application/json' \
-d '{ "name": "Orders", "url": "https://api.example.com/hooks", "events": ["db.insert", "db.update"], "tables": ["public.orders"] }'| Request | Does |
|---|---|
GET /projects/<ref>/webhooks | Webhooks with delivery counts, plan limits and this month's events |
POST /projects/<ref>/webhooks | Create (returns the signing secret) |
PATCH / DELETE /projects/<ref>/webhooks/<id> | Change (name, url, events, tables, headers, enabled) or delete |
GET …/<id>/secret, POST …/<id>/rotate-secret | Show or rotate the signing secret |
POST …/<id>/test | Send a test.ping now |
GET …/<id>/deliveries?status=dead | The delivery log (pending, delivered or dead) |
GET …/<id>/deliveries/<delivery> | One delivery with payload, response and attempts |
POST …/<id>/replay | { "delivery_id": "…" } for one, or { "status": "dead", "since": "…" } for all failed ones |
Changing webhooks needs the Administrator role on the project (tokens: projects:write). To call other services from SQL yourself, see HTTP from SQL.