How Webhooks Work (Sending and Receiving Safely)
A webhook is an HTTP POST that one system sends to a URL you choose when something happens: a payment succeeds, a row changes, a user signs up. Instead of your code asking "anything new?" every minute (polling an API), the other side calls you the moment the event occurs. To use webhooks safely, the sender signs each request and retries failures, and the receiver checks the signature, answers fast and ignores duplicates.
#Webhooks vs API
They are not alternatives so much as two directions of the same thing.
| API call | Webhook | |
|---|---|---|
| Who starts it | You (the client) | The other system (the sender) |
| When | Whenever you ask | When an event happens |
| Typical use | Read or change data | Get told that something changed |
| Freshness | As fresh as your polling interval | Seconds |
| Cost when nothing happens | A request per poll | Nothing |
| You must run | A client | A public HTTPS endpoint |
| Failure handling | You retry your own call | The sender retries delivery |
Most integrations use both: a webhook says "order 42 was paid", and your handler calls the API to fetch details or update something.
#How a webhook delivery works
- You register an endpoint. In the sender's dashboard you enter a URL (
https://api.example.com/hooks/stripe) and pick events. The sender gives you a signing secret. - An event happens. The sender writes it to a queue, not straight to your URL, so a slow receiver does not slow the sender.
- The sender POSTs it. A JSON body with an event id, type, timestamp and the data, plus headers with a signature.
- You verify and answer. Check the signature, store or enqueue the event, answer 2xx within a few seconds.
- The sender retries if needed. No 2xx, a timeout or a network error means another attempt later, with growing delays.
- You process. Do the slow work after you answered.
#Sending webhooks safely
If your app sends webhooks to customers, or you are deciding whether a platform does it well, these are the parts that matter.
#Sign every request
Compute an HMAC-SHA256 over the timestamp and the raw body with a per-endpoint secret, and send it in a header. Including the timestamp lets receivers reject old requests that someone captured and replays. The Standard Webhooks specification describes a common header scheme (webhook-id, webhook-timestamp, webhook-signature) that many providers now follow.
#Retry with backoff
Endpoints go down. Retry over hours, not seconds, with growing gaps, and keep a log so failed events can be sent again.
#Promise "at least once", not "exactly once"
A receiver can process an event and then time out before answering. The sender sees a failure and retries. Duplicates are unavoidable; give every event a stable id so receivers can skip repeats.
#Do not promise order
Retries and parallel delivery mean event 2 can arrive before event 1. Include the event time and, where order matters, the full current state of the object.
#Block internal targets
A webhook URL is user input that makes your server send requests. Refuse private network addresses and loopback, or anyone can probe your internal network through your webhook feature.
#How StitchDB does it
StitchDB database webhooks send inserts, updates and deletes on chosen tables, and Auth events (auth.user.created, auth.user.confirmed, auth.user.signed_in, auth.user.updated, auth.user.deleted) to your HTTPS endpoint, without triggers or functions to write. Events are queued, so a slow endpoint never slows the database.
{
"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 }
}- Headers:
StitchDB-Webhook-Id(same on every retry),StitchDB-Webhook-Timestamp,StitchDB-EventandStitchDB-Signature: v1=<hex>, an HMAC-SHA256 of<timestamp>.<raw body>. - Any 2xx within 15 seconds counts as delivered. Otherwise retries follow after 10 s, 1 min, 5 min, 30 min, 2 h, 6 h, 12 h and 24 h.
- Delivery is at least once, and order between events is not guaranteed.
- Studio shows every delivery with the body, status, the first 2 KB of your response and each attempt. Replay failed resends all failed deliveries, oldest first.
- URLs must be public
https://addresses; redirects are not followed.
Limits per plan: 2 webhooks and 10,000 events a month on Free, 20 and 1,000,000 on Pro, 50 and 5,000,000 on Scale.
#Receiving webhooks safely
#1. Verify against the raw body
The signature covers the exact bytes the sender sent. Parse JSON first and re-serialize, and the bytes change. Read the raw body, verify, then parse.
import crypto from 'node:crypto'
import express from 'express'
const app = express()
app.post('/hooks/stitchdb', express.raw({ type: 'application/json' }), (req, res) => {
const ts = req.get('stitchdb-webhook-timestamp')
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return res.status(401).end()
const want = 'v1=' + crypto
.createHmac('sha256', process.env.WEBHOOK_SECRET)
.update(ts + '.' + req.body)
.digest('hex')
const got = String(req.get('stitchdb-signature') || '')
if (got.length !== want.length || !crypto.timingSafeEqual(Buffer.from(got), Buffer.from(want))) {
return res.status(401).end()
}
const event = JSON.parse(req.body)
queue.add(event) // do the real work later
res.status(200).end() // answer fast
})Two details: compare in constant time (timingSafeEqual), and refuse timestamps older than a few minutes.
#2. Answer fast, work later
Senders time out (15 seconds for StitchDB, often less elsewhere). Store the event, answer 200, then process it from a queue or background job. Calling three other APIs before you answer is how you get duplicate deliveries.
#3. Make processing idempotent
Keep the ids you have processed and skip repeats:
create table public.processed_events (
event_id text primary key,
processed_at timestamptz not null default now()
);
-- in your handler, inside the same transaction as the work:
insert into public.processed_events (event_id) values ($1)
on conflict (event_id) do nothing
returning event_id; -- no row returned means: already handled, skip#4. Handle order yourself
If order.updated can arrive before order.created, either fetch the current object from the sender's API, or compare a timestamp or version column and ignore stale events.
#5. Keep secrets out of URLs and logs
Signing secrets belong in environment variables. Do not log full headers; they contain signatures and sometimes tokens.
#Receiving without running a server
Writing a receiver for every service (Stripe, GitHub, Shopify) means a public server, a signature check per sender and a dedupe table. StitchDB inbound webhooks do that part for you:
- In Studio open Integrations › Inbound webhooks and turn them on.
- Add an endpoint, for example
stripe. Its URL ishttps://<project-ref>.p.stitchdb.com/inbound/v1/stripe. - Paste the URL into the sender's dashboard and the sender's signing secret into Studio.
Stripe, GitHub, Shopify and Standard Webhooks signatures (used by Svix, Clerk, Resend and others) are checked out of the box; other senders work with an HMAC header you describe or a secret token. Each verified event is stored once as a row in inbound.events (an event id the endpoint already has is answered 200 and not stored again), and it is stored before the sender gets its 200. React with a trigger:
create or replace function public.on_stripe_event() returns trigger
language plpgsql security definer set search_path = public as $$
begin
if new.event_type = 'checkout.session.completed' then
update orders set paid = true
where checkout_id = new.payload -> 'data' -> 'object' ->> 'id';
end if;
return new;
end $$;
create trigger on_stripe_event after insert on inbound.events
for each row when (new.endpoint = 'stripe')
execute function public.on_stripe_event();Keep triggers quick, because they run before the sender gets its answer. For slow work, hand each event to an Edge Function, which is retried with backoff until it succeeds. For a full payment flow, see Stripe subscriptions backend.
#A checklist
Senders: HMAC signature with a timestamp, stable event ids, retries over hours, a delivery log with replay, private addresses blocked.
Receivers: verify the raw body in constant time, reject old timestamps, answer 2xx fast, process from a queue, dedupe by event id, do not rely on order.
#FAQ
#What is the difference between a webhook and an API?
With an API, you call the other system. With a webhook, the other system calls you when something happens.
#Are webhooks secure?
They are when the receiver verifies the signature and timestamp on every request. Without that, anyone who finds the URL can send fake events.
#What happens if my endpoint is down?
A good sender retries. StitchDB retries for 24 hours, e-mails the project owner after an hour of failures and turns the webhook off after 3 days; missed events stay in the log for Replay failed.
#Why did I receive the same webhook twice?
Delivery is at least once. A timeout after you processed the event causes a retry. Skip events whose id you already handled.
#Can I send webhooks from SQL?
Yes. Database webhooks need no code; for custom calls, pg_net sends HTTP requests from SQL and triggers (HTTP from SQL).
Get signed, retried webhooks from your tables: create a free project and open Database › Webhooks.