When you ship webhooks, you are asking other engineering teams to write integrations against your API. Those integrations will be deployed to environments you do not control, run by engineers you will never meet, and run for years after you stop thinking about them. The contract has to be obvious and the implementation has to be safe.
This post is the things that production webhook senders actually do — signing, retries, replay protection, and the schema choices that make integrators' lives easier.
What Goes in the Payload
The minimum useful payload:
{
"id": "evt_8a7f3c91-e842-4d",
"type": "invoice.paid",
"created_at": "2026-05-12T14:32:01Z",
"data": {
"id": "inv_abc123",
"amount": 4500,
"currency": "USD",
"customer_id": "cus_def456"
}
}
The id is a unique identifier per webhook — not per business event. If you retry, the same event has the same id. The integrator uses this for deduplication.
The type is the event type, namespaced by resource. invoice.paid, subscription.canceled, user.created — the convention scales as you add events.
The data is the payload of the event itself. Include enough that the integrator does not have to call back to your API for the common case. Resist including everything; the payload is a contract you cannot easily change later.
Signing
Webhook signing is the only way an integrator can verify that a request came from you. Without it, anyone who knows the URL can send fake events.
The standard pattern is HMAC-SHA256 over the raw request body, with a shared secret per integrator.
// Sending
$body = json_encode($payload);
$timestamp = time();
$signature = hash_hmac('sha256', "{$timestamp}.{$body}", $secret);
Http::withHeaders([
'X-Webhook-Signature' => "t={$timestamp},v1={$signature}",
'Content-Type' => 'application/json',
])->post($integrator->webhook_url, [], $body);
The timestamp is included to defeat replay attacks (more on that below). The signature covers both the timestamp and the body, so an attacker cannot change either.
On the integrator side:
$payload = $request->getContent();
$signatureHeader = $request->header('X-Webhook-Signature');
parse_str(str_replace(',', '&', $signatureHeader), $parts);
$timestamp = $parts['t'];
$received = $parts['v1'];
if (abs(time() - $timestamp) > 300) {
abort(401, 'Timestamp too old');
}
$expected = hash_hmac('sha256', "{$timestamp}.{$payload}", $secret);
if (!hash_equals($expected, $received)) {
abort(401, 'Invalid signature');
}
hash_equals is constant-time to prevent timing attacks. Use it; do not use ===.
Replay Protection
A signed request that an attacker captures can be sent again. The timestamp tolerance window prevents this from working past a few minutes.
A stricter version stores recently-seen event IDs and rejects duplicates:
if (Cache::has("webhook:seen:{$event['id']}")) {
return response('Already processed', 200);
}
Cache::put("webhook:seen:{$event['id']}", true, now()->addHours(1));
Most integrators are fine with the timestamp check. Add ID-level deduplication if you ship events that are particularly sensitive — financial transactions, security-relevant changes.
Retries with Exponential Backoff
Webhook deliveries fail constantly. The integrator's server is down, their network is having a bad day, their app deployed a bug. Production webhook senders retry aggressively but considerately.
A reasonable retry policy:
| Attempt | Delay before |
|---|---|
| 1 (initial) | 0 |
| 2 | 5 seconds |
| 3 | 30 seconds |
| 4 | 5 minutes |
| 5 | 30 minutes |
| 6 | 2 hours |
| 7 | 12 hours |
| 8 | 24 hours (final) |
After the final retry, the webhook is marked failed and recorded for manual review or expiration. Many integrators want a notification when their webhook delivery is failing — a daily digest of failed deliveries goes a long way.
The retry queue is the operationally hard part. Stripe-grade retry semantics require durable storage of every pending delivery, a worker pool, and observability into the queue's health. Plan for it.
What Counts as Success
The integrator's response decides whether you retry. The conventions:
- 2xx response. Success. Mark delivered.
- 410 Gone. The endpoint is permanently gone. Stop retrying; disable the webhook.
- Anything else (4xx, 5xx, timeout, connection error). Retry per the policy.
Some senders interpret all 2xx as success but log a warning for 2xx-other-than-200. Some treat 4xx as terminal and never retry. Pick a policy, document it, and stick with it. Stripe's documented retry policy is a good reference.
Endpoint Verification on Setup
When an integrator registers a webhook URL, verify that the URL is real and that they actually control it. The standard pattern:
- Send a verification request with a random token in the body
- The endpoint must echo the token back (or compute an HMAC of it)
- Only after the round trip succeeds, save the URL
$token = Str::random(32);
$response = Http::timeout(10)->post($url, [
'type' => 'webhook.verification',
'verification_token' => $token,
]);
if ($response->json('verification_token') !== $token) {
throw new WebhookVerificationFailed();
}
This stops accidental misconfiguration and prevents using your service as an oblique tool for SSRF or spam.
Ordering
Webhooks are not naturally ordered. Two events for the same resource may arrive in either order, especially if the first one had to retry and the second one delivered on first attempt.
Two options:
Document that order is not guaranteed. The integrator must use timestamps or sequence numbers in the payload to reconcile. This is the standard at Stripe and most major API providers.
Guarantee per-resource ordering via partitioning. Webhooks for the same resource are delivered through a single worker; later events wait for earlier events to succeed (with their retries) before being sent. More complex, more honest about ordering.
The first option is the cheap default. Pick it unless the integrator pain is severe enough to justify the operational cost of the second.
Observability for the Sender
The sender needs to know:
- Which webhooks are succeeding and failing
- Which integrators have a high failure rate
- How long deliveries are taking
- How big the retry queue is
Expose a dashboard to integrators showing recent deliveries: status, payload, response code, retry count. Stripe, GitHub, and most major API providers do this; it cuts support load by an order of magnitude.
Common Mistakes
- No signing. Anyone who knows the URL can send fake events.
- No replay protection. A captured webhook can be replayed by an attacker.
- Synchronous delivery from the request that triggered it. A slow integrator pins your application threads. Always send through a queue.
- No retry policy. Transient failures cause permanent data loss.
- Sending data the integrator did not ask for. Webhooks are a public contract; minimize the payload.
- No way for the integrator to test. Provide a way to trigger a test event without performing the business action.
The Sender's Goal
A good webhook sender is invisible. Integrators set it up once, integrate it once, and never have to debug why an event was missed. Getting there requires more discipline than the protocol itself suggests — signing, retries, observability, deduplication, ordering — but the payoff is integrators who stay and customers who do not get paged at 2 AM.
Building a webhook system that integrators have to trust for years? We help teams design webhook contracts that survive contact with production and the customers who depend on them. scopeforged.com