Build

Webhooks

A webhook POSTs a signed JSON event to your URL whenever a row is inserted, updated, or deleted. Use it to send email, sync a search index, or call another service.

Payload

{
  "id": "evt_...",
  "type": "row.insert",
  "app": "notes_app",
  "table": "notes",
  "record": {"id": "...", "title": "Buy milk", "created_at": "2026-09-22T18:00:00Z"},
  "old_record": null,
  "occurred_at": "2026-09-22T18:00:00Z"
}

type is row.insert, row.update, row.delete, or ping for a test. Each request carries these headers:

HeaderValue
Berth-Signaturet=<unix seconds>,v1=<hex HMAC-SHA256 of "t.raw body" with the webhook secret>
Berth-Event-Idthe event id, the same on every retry
Berth-Delivery-Idthis attempt

Create a webhook

Point it at an HTTPS endpoint you control. Leave out table for every table and events for all three types. The signing secret is returned once.

berth tables create --app "$APP" notes title:text:notnull
HOOK=$(curl -sS -X POST https://api.atberth.com/v1/apps/$APP/webhooks \
  -H "Authorization: Bearer $SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{"url":"https://example.com/berth-webhook","table":"notes","events":["insert","update"],"description":"search sync"}')
WEBHOOK_ID=$(echo "$HOOK" | jq -er '.webhook.id')
WEBHOOK_SECRET=$(echo "$HOOK" | jq -er '.secret')
berth webhooks list --app "$APP"

With the CLI: berth webhooks add --app "$APP" https://example.com/berth-webhook --table notes --events insert,update. Private, loopback, and cloud metadata addresses are refused.

Test and inspect deliveries

Send a ping, write a row, then look at what was sent and what your server answered:

berth webhooks test --app "$APP" "$WEBHOOK_ID"
berth rows add --app "$APP" notes title="Hook me"
sleep 2
berth webhooks deliveries --app "$APP" "$WEBHOOK_ID"
curl -sS https://api.atberth.com/v1/apps/$APP/webhooks/$WEBHOOK_ID/deliveries \
  -H "Authorization: Bearer $SECRET_KEY"

Retries

Any 2xx answer within 10 seconds counts as delivered. Anything else is retried after 10 seconds, 1 minute, 5 minutes, 30 minutes, and 2 hours: six attempts in all. Retries reuse Berth-Event-Id, so store the ids you have handled and skip repeats.

Verify the signature

Always check Berth-Signature against the raw body before trusting an event, and reject old timestamps to stop replays. You can compute a signature in a shell to see the format:

BODY='{"id":"evt_test","type":"ping"}'
T=$(date +%s)
SIG=$(printf '%s.%s' "$T" "$BODY" | openssl dgst -sha256 -hmac "$WEBHOOK_SECRET" | sed 's/^.* //')
echo "Berth-Signature: t=$T,v1=$SIG"

Node

import crypto from "node:crypto";
import express from "express";
const app = express();
const secret = process.env.BERTH_WEBHOOK_SECRET;
app.post("/berth-webhook", express.raw({ type: "application/json" }), (req, res) => {
  const header = req.get("Berth-Signature") ?? "";
  const parts = Object.fromEntries(header.split(",").map((p) => p.split("=")));
  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${parts.t}.${req.body}`)
    .digest("hex");
  const fresh = Math.abs(Date.now() / 1000 - Number(parts.t)) < 300;
  const ok = parts.v1 && fresh &&
    crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1));
  if (!ok) return res.status(400).send("bad signature");
  const event = JSON.parse(req.body);
  console.log(event.type, event.table, event.record?.id);
  res.sendStatus(204);
});
app.listen(3000);

Python

import hashlib, hmac, json, os, time
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ["BERTH_WEBHOOK_SECRET"].encode()
@app.post("/berth-webhook")
def berth_webhook():
    raw = request.get_data()
    parts = dict(p.split("=", 1) for p in request.headers.get("Berth-Signature", "").split(","))
    expected = hmac.new(SECRET, parts.get("t", "").encode() + b"." + raw, hashlib.sha256).hexdigest()
    fresh = abs(time.time() - int(parts.get("t", "0"))) < 300
    if not (fresh and hmac.compare_digest(expected, parts.get("v1", ""))):
        abort(400)
    event = json.loads(raw)
    print(event["type"], event["table"], event["record"])
    return "", 204

Update, disable, remove

berth webhooks update --app "$APP" "$WEBHOOK_ID" --events insert,update,delete --disable
berth webhooks show --app "$APP" "$WEBHOOK_ID"
curl -sS -X PATCH https://api.atberth.com/v1/apps/$APP/webhooks/$WEBHOOK_ID \
  -H "Authorization: Bearer $SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{"enabled":true}'
berth webhooks remove --app "$APP" "$WEBHOOK_ID"

Up to 20 webhooks per app.