0

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:

  1. You register a webhook URL with the service (e.g., Stripe, GitHub, Shopify): "POST to https://myapp.com/webhooks/stripe when a payment succeeds."
  2. When the event occurs, their server sends a POST request to your URL with a JSON payload.
  3. 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-Signature header 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);
}