Webhook — server-to-server push over HTTP
Beginner5 min read·eng-13-010
interview
Concept
Webhook — a server-to-server push notification over HTTP. Instead of your server polling an external service ("did anything happen?"), the external service calls your server when something happens.
Pull vs Push:
- Polling (pull): You call their API every minute. 99.9% of calls return "nothing new." Wasteful. Delayed.
- Webhook (push): You give them a URL. They POST to your URL when something happens. Instant, efficient.
How webhooks work:
- You register a webhook URL with the service (e.g., Stripe, GitHub, Shopify): "POST to
https://myapp.com/webhooks/stripewhen a payment succeeds." - When the event occurs, their server sends a POST request to your URL with a JSON payload.
- Your server handles it and responds with 2xx quickly.
Webhook design considerations:
- Respond fast: Return 200 within a few seconds. Process heavy work asynchronously (queue it). If you timeout, the sender may retry.
- Verify the signature: Never trust a webhook without verifying it came from the expected source. Stripe sends
Stripe-Signatureheader with an HMAC. Verify it. - Be idempotent: Webhooks are often retried. Handle duplicate deliveries (store event IDs, use
INSERT IGNORE). - Respond 200 to everything valid: Return 200 even if you've seen the event before. Non-2xx tells the sender to retry.
- Webhook secret: A shared secret used to compute/verify the HMAC signature.
Failure handling (sender's responsibility):
- Retry with exponential backoff if your endpoint returns 5xx or times out.
- Dead letter queue for permanently failed webhooks.
Code Example
php
<?php
// Receiving a Stripe webhook
Route::post('/webhooks/stripe', function (Request $request) {
// 1. Verify signature FIRST — reject forged webhooks
$payload = $request->getContent();
$sigHeader = $request->header('Stripe-Signature');
$secret = config('services.stripe.webhook_secret');
try {
$event = \Stripe\Webhook::constructEvent($payload, $sigHeader, $secret);
} catch (\Stripe\Exception\SignatureVerificationException $e) {
return response('Invalid signature', 400); // reject!
}
// 2. Queue heavy processing — respond FAST, work later
StripeWebhookJob::dispatch($event->type, $event->data->object->toArray())
->onQueue('webhooks');
return response('OK', 200); // respond immediately
})->withoutMiddleware([\App\Http\Middleware\VerifyCsrfToken::class]); // webhooks skip CSRF
// Processing webhook events (in the queued job)
class StripeWebhookJob implements \Illuminate\Contracts\Queue\ShouldQueue
{
public function __construct(
public readonly string $eventType,
public readonly array $data,
) {}
public function handle(): void
{
match ($this->eventType) {
'payment_intent.succeeded' => $this->handlePaymentSuccess($this->data),
'invoice.payment_failed' => $this->handlePaymentFailed($this->data),
'customer.subscription.deleted' => $this->handleSubscriptionCancelled($this->data),
default => null, // ignore unknown events
};
}
private function handlePaymentSuccess(array $paymentIntent): void
{
// Idempotent: use INSERT IGNORE / updateOrCreate
Order::where('payment_intent_id', $paymentIntent['id'])
->update(['status' => 'paid', 'paid_at' => now()]);
}
}
// Manual HMAC verification (without Stripe SDK)
function verifyWebhookSignature(string $payload, string $sigHeader, string $secret): bool
{
// Stripe-Signature: t=1234567890,v1=abc123...
preg_match('/t=(\d+)/', $sigHeader, $tMatch);
preg_match('/v1=([a-f0-9]+)/', $sigHeader, $vMatch);
$timestamp = $tMatch[1];
$signedPayload = "{$timestamp}.{$payload}";
$expectedSig = hash_hmac('sha256', $signedPayload, $secret);
return hash_equals($expectedSig, $vMatch[1]);
}
// Sending a webhook (being the webhook sender)
function dispatchWebhook(string $url, array $payload, string $secret): void
{
$body = json_encode($payload);
$timestamp = time();
$sig = hash_hmac('sha256', "{$timestamp}.{$body}", $secret);
Http::withHeaders([
'Content-Type' => 'application/json',
'X-Webhook-Signature' => "t={$timestamp},v1={$sig}",
])->post($url, $payload);
}