Sending Webhooks from Your SaaS: Signing, Retries, and Muting the Noise
Most SaaS products eventually need to push events to other systems: an invoice was paid, a user was created, a pipeline stage changed. The obvious answer is webhooks, and there is no shortage of advice about receiving them. Sending them is a different job, and it trips teams up in different places.
The part people underestimate is the contract. Once a customer's inventory system depends on your order.shipped event, changing or dropping events is a breaking API change, and it deserves the same care as any endpoint.
Get the payload basics right
Every event needs three things, and skipping them causes support tickets long after you've forgotten this code:
- A stable event name.
invoice.paid, notInvoiceEvent2. When you need a second payload shape for the same event, it's a new version, not a quiet field swap. - An event ID and a timestamp. Receivers use these to deduplicate and to detect replays. Send them at the top level, not buried in the data.
- IDs, not references. Include the IDs of related objects so consumers can fetch details if your payload doesn't carry everything.
{
"id": "evt_0gk3x2",
"type": "invoice.paid",
"created_at": "2026-09-29T08:14:11+00:00",
"data": { "invoice_id": "inv_8821", "amount": 8.00, "currency": "USD" }
}
Keep payloads small. The moment you embed a full object graph, every schema change becomes a migration for every receiver.
Sign everything
Unsigned webhooks mean your users' endpoints accept forged events from anyone who knows the URL. Sign every payload with HMAC-SHA256 using a per-endpoint secret, and include a timestamp in the signed material so old payloads can be rejected:
$timestamp = time();
$signature = hash_hmac('sha256', "$timestamp.$rawBody", $endpoint->signing_secret);
// header: X-SrcTeam-Signature: t=1759131251,v1=ac9d0f...
Two details matter more than they look. Sign the raw body exactly as it will be sent: if you sign a JSON string and then send a re-encoded one, verification fails in ways that take an afternoon to find. And rotate secrets without drama: accept the old signature for a grace window after a new secret is issued, or every rotation locks receivers out.
Deliver like an adult
The delivery loop is where naive implementations die. A few defaults that hold up:
- Timeouts on every attempt. Five to ten seconds. Without them, one customer's dead endpoint stalls your whole worker pool.
- Retry failures with backoff. Exponential backoff with jitter, capped. Eight attempts spread over a day handles a customer restarting their server at 3am without hammering it.
- Read the status code. A 2xx means delivered. A 429 means slow down. Other 4xx means your payload is wrong for them and retrying won't fix it. 5xx means try again later. Treating all failures identically is how you end up retrying a 401 forty times.
- Disable dead endpoints. After sustained failures over several days, stop delivering to an endpoint and tell the owner. A zombie endpoint silently eats queue capacity forever otherwise.
You don't need dedicated message-queue infrastructure to start with. A jobs table with one row per delivery attempt, run by your normal queue workers, carries a small SaaS comfortably. Split it out when the volume gives you a real reason, not before.
Make deliveries visible
This is the part customers judge hardest, because it's the part they see. Give them a page listing recent deliveries per endpoint: event type, response code, timestamp, and a redeliver button. When their side misses an event, a redelivery button beats an email to you by a wide margin.
For endpoints that get disabled or stay failing, send the endpoint owner an email. Silence is the one outcome nobody forgives, because the customer finds out something's missing when their reconciliation already broke.
The payoff
A webhook sender is mostly plumbing: sign, attempt, retry, log, show. None of it is hard, and all of it is cheap to build in before the first customer depends on it and expensive to retrofit after, when the first delivery incident has already happened.