Blog · · 6 min read
How to Send Webhooks: Every Part You Have to Build
Sending a webhook is one POST. Sending them reliably takes eleven parts, from URL checks and signing to retries, logs, alerts, auto-disabling and a customer portal.
Key points
- The send itself is a single HTTP POST. The work is in everything around it - retries, signatures, delivery logs and stopping dead endpoints.
- A production sender has eleven parts: endpoint registration with URL checks, event types, payload shape, signing, a queue with retries, timeouts, logs, alerts, auto-disabling, secret rotation and a customer portal.
- Build signing, queued retries and delivery logs first; the rest can follow.
Sending a webhook is one HTTP POST. Sending webhooks to customers in production is a small system: you need to retry what didn't arrive, sign what you send, record what happened, and stop hammering endpoints that are gone. This post lists every part you end up building, what to decide for each, and a minimal Node.js sender you can start from.
The parts
A webhook sender that customers can rely on has these eleven pieces:
- Endpoint registration and URL validation. Customers register URLs and pick event types. Validation includes SSRF checks so nobody can point you at your own network.
- Event types. Names like
invoice.paidand the catalog of them. - Payload shape. How the type, timestamp and data are laid out.
- Signing. An HMAC (or public-key) signature so receivers can verify the sender.
- A queue and retries. Decouple sending from your business logic and retry failures with growing delays.
- Timeouts and success rules. How long to wait and which responses count as delivered.
- Delivery logs. Status code, response snippet, duration and errors for every attempt.
- Failure alerts. Tell your team, and the endpoint's owner, when deliveries keep failing.
- Auto-disable and re-enable. Stop sending to endpoints that have failed for days, and let them come back.
- Secret rotation. Replace a signing secret without breaking receivers.
- A customer portal. Let customers manage endpoints, read logs and replay deliveries themselves.
Registration and URL validation
Model endpoints as customer plus URL, with an optional list of event types per URL. At registration, at minimum: accept only https, reject credentials in the URL (user:pass@), reject IP-literal hosts, and reject internal names such as localhost or *.internal. Then check again right before each send that the hostname doesn't resolve to a private address, because DNS answers can change after registration. The SSRF post goes through the full list.
Event types and payload shape
resource.action names (invoice.paid, customer.deleted) let receivers route by prefix. For the body, a type/timestamp/data envelope is easy to consume, and it is what the Standard Webhooks spec recommends. The same spec suggests keeping payloads small, usually under 20 KB.
Signing
Use a scheme receivers already have libraries for rather than inventing one. Standard Webhooks defines:
- Three headers:
webhook-id,webhook-timestamp,webhook-signature - Signed content
id.timestamp.body, HMAC-SHA256, base64 - A secret written as
whsec_plus base64 random bytes (the spec recommends 24 to 64 bytes)
The ID is signed so nobody can change the value receivers deduplicate on. The timestamp is signed so an old captured request can't be replayed later. Standard Webhooks explained goes through the reasoning behind each field.
Queue and retries
Sending inside the request that created the event makes your code wait on the customer's server, and loses the event if that server is down. Write the event, enqueue it, and let a worker send. If you write the business row and the event row in the same database transaction (the transactional outbox pattern), you also avoid "saved the order, never emitted the event" (the outbox pattern).
Retry with short delays first and longer ones as failures continue. We covered schedules, jitter and when to give up in designing webhook retries. Keep webhook-id the same across retries; regenerate the timestamp and signature on every attempt.
Timeouts and what counts as success
- Timeout: 10 to 15 seconds is typical. GitHub gives receivers 10 seconds and Shopify 5 (checked on 2026-09-27 in GitHub's and Shopify's docs). Longer timeouts let slow endpoints tie up your workers.
- Success: 2xx only.
- Redirects: Don't follow them. Following a redirect sends the payload somewhere you never validated.
A minimal sender in Node.js
One attempt: sign, send, stop after 15 seconds, don't follow redirects. Uses the built-in fetch (Node 18+) and AbortSignal.timeout.
import { createHmac, randomUUID } from 'node:crypto';
const SECRET = Buffer.from(process.env.ENDPOINT_SECRET!.replace(/^whsec_/, ''), 'base64');
export async function sendOnce(url: string, msgId: string, type: string, data: unknown) {
const body = JSON.stringify({ type, timestamp: new Date().toISOString(), data });
const ts = Math.floor(Date.now() / 1000).toString();
const sig = createHmac('sha256', SECRET).update(`${msgId}.${ts}.${body}`).digest('base64');
const started = Date.now();
try {
const res = await fetch(url, {
method: 'POST',
headers: {
'content-type': 'application/json',
'webhook-id': msgId,
'webhook-timestamp': ts,
'webhook-signature': `v1,${sig}`,
},
body,
redirect: 'manual', // return 3xx as is instead of following it
signal: AbortSignal.timeout(15_000), // aborts with a TimeoutError
});
const head = (await res.text()).slice(0, 1024);
return { ok: res.ok, status: res.status, head, ms: Date.now() - started };
} catch (e) {
return { ok: false, status: null, head: null, ms: Date.now() - started, error: String(e) };
}
}
// One ID per event, reused on every retry
const msgId = `msg_${randomUUID()}`;AbortSignal.timeout() returns a signal that aborts with a TimeoutError after the given time (MDN). With redirect: 'manual', Node's fetch hands you the 3xx response itself, so res.ok is false and the attempt counts as failed. The caller logs the result and, on failure, schedules the next attempt.
Delivery logs
Log every attempt. These records answer support tickets and let you show a customer exactly what their server returned.
| Field | Why |
|---|---|
| Event ID, type, endpoint | What went where |
| Attempt number and time | Retry history |
| Status code and first ~1 KB of the response | The receiver's error message |
| Duration and connection errors | Timeouts, DNS and TLS failures |
| The payload | Replays |
Set a retention period and delete old records, shorter if payloads contain personal data. What to log for outgoing webhooks goes deeper.
Alerts and auto-disabling
An endpoint that keeps failing costs a worker slot on every retry. Handle it in two stages: pause briefly after a burst of consecutive failures, and disable the endpoint after failures have lasted days. Tell both your team and the endpoint's owner when you disable it; Stripe, for example, emails account owners when webhook deliveries keep failing (Stripe).
Secret rotation
Customers will need to replace secrets, after a leak or on a schedule. Receivers can't switch at the same instant you do, so for a period sign with both the old and new secret and put both signatures in webhook-signature, separated by a space. Stripe lets the old secret stay active for up to 24 hours (Stripe).
Customer portal
As your customer count grows, so do "please change our URL" and "we didn't get the event" tickets. A page where customers register endpoints, read their delivery logs and replay failures removes most of them. If you embed it in your own app, issue short-lived, per-customer links so no customer can see another's data.
Build order
You don't have to ship everything at once:
- Signing, queued retries, delivery logs
- Timeouts, redirect handling, URL validation
- Failure alerts and auto-disabling
- Secret rotation and the customer portal
Where Webhook Admin fits
Webhook Admin covers parts 1 and 4 through 11 behind an API and a dashboard. You decide the event types and payloads, then call POST /v1/messages with the customer ID, event type and payload.
- Endpoint URLs must be
httpson port 443 or 8443. IP-literal hosts, internal names and names that resolve to private addresses are rejected, and redirects are never followed. - Signatures follow Standard Webhooks; after a rotation, messages carry both signatures for 24 hours.
- Retries: by default immediately, 5 s, 5 min, 30 min, 2 h, 5 h, 10 h, 10 h (8 attempts, about 28 hours); you can choose the number of retries and the interval per endpoint. The timeout is 15 seconds.
- Every attempt is logged with the status code, the first 1,024 bytes of the response and the duration, and can be replayed from the dashboard or the API.
- Endpoints that fail for five days are disabled automatically, with alerts to Slack, Microsoft Teams, email, a webhook or Chatwork.
- The customer portal is available from the Starter plan.
The getting started guide walks through the setup and pricing lists the plans. If you'd rather not build the retry, logging and alerting parts yourself, you can sign up for free (50,000 messages a month): https://app.webhookadmin.com/signup
FAQ
Which queue should I use?
Whatever you already run. SQS, Cloud Tasks, Sidekiq or BullMQ all work, as long as they can delay a job and run it again on failure. You still need your own per-endpoint failure tracking and a way to replay deliveries after the queue gives up.
Can't I just send the webhook synchronously in the request handler?
If the customer's endpoint is slow, your own request waits for it, and if it is down the event is lost. Record the event in your transaction and let a worker send it.
What should I build first?
Signatures, queued retries and a delivery log. Without signatures receivers cannot trust you, without retries a short outage on their side drops events, and without logs you cannot answer "did you send it?"