Webhook Handling Patterns trong Laravel: Nhận, Xác Thực, Xử Lý

· 10 min read

Webhooks là HTTP callbacks — dịch vụ bên ngoài POST dữ liệu vào app khi có sự kiện. Stripe gửi xác nhận thanh toán. GitHub gửi push events. Shopify gửi order updates. Xử lý sai dẫn đến mất dữ liệu, xử lý trùng lặp, và lỗ hổng bảo mật.

Bài viết này cover patterns mà mọi production webhook handler cần: signature verification, idempotency, async processing, retry handling, logging, và monitoring.

Ba Quy Tắc Webhook Handling

  1. Xác thực — Xác nhận request thực sự từ nguồn mong đợi (không phải attacker)
  2. Acknowledge — Trả 200 ngay lập tức, trước khi xử lý
  3. Xử lý — Handle payload async trong queue job
Dịch vụ → POST /webhook → Verify Signature → Return 200 → Queue Job → Xử lý
                              ↓ (fail)
                           Return 403 → Log attempt → Alert

Tại sao trả 200 trước khi xử lý? Webhook senders có timeout (thường 5-30 giây). Nếu processing mất lâu hơn, sender nghĩ request fail → retry → bạn xử lý trùng. Trả 200 trước tiên, xử lý sau trong queue.

Webhook Controller Cơ Bản

namespace App\Http\Controllers\Webhooks;

use App\Jobs\ProcessStripeWebhook;
use Illuminate\Http\Request;
use Illuminate\Http\Response;

class StripeWebhookController extends Controller
{
    public function handle(Request $request): Response
    {
        // Bước 1: Xác thực — request có thật từ Stripe?
        $this->verifySignature($request);

        // Bước 2: Log raw payload (cho debugging)
        Log::info('Stripe webhook received', [
            'type' => $request->input('type'),
            'id' => $request->input('id'),
        ]);

        // Bước 3: Dispatch job (xử lý async)
        ProcessStripeWebhook::dispatch(
            payload: $request->all(),
            eventType: $request->input('type'),
        );

        // Bước 4: Trả 200 ngay lập tức
        return response('OK', 200);
    }

    private function verifySignature(Request $request): void
    {
        $signature = $request->header('Stripe-Signature');
        $secret = config('services.stripe.webhook_secret');

        try {
            \Stripe\Webhook::constructEvent(
                $request->getContent(),
                $signature,
                $secret,
            );
        } catch (\Stripe\Exception\SignatureVerificationException $e) {
            Log::warning('Stripe webhook signature verification failed', [
                'ip' => $request->ip(),
                'signature' => $signature,
            ]);
            abort(403, 'Invalid signature');
        }
    }
}

Route — Tắt CSRF

// routes/web.php
Route::post('/webhooks/stripe', [StripeWebhookController::class, 'handle'])
    ->withoutMiddleware([\App\Http\Middleware\VerifyCsrfToken::class])
    ->name('webhooks.stripe');

Quan trọng: Exclude webhook routes khỏi CSRF. Dịch vụ bên ngoài không có CSRF token. Không exclude = webhook luôn fail với 419.

Xác Thực Signature — Chi Tiết

HMAC-SHA256 (Phổ biến nhất: Stripe, GitHub, Shopify, Twilio)

Hầu hết providers dùng cùng pattern: hash payload bằng shared secret, so sánh với signature trong header.

class WebhookSignatureVerifier
{
    /**
     * Xác thực HMAC-SHA256 signature.
     *
     * QUAN TRỌNG: Dùng hash_equals() thay vì === để prevent timing attacks.
     * Nếu dùng ===, attacker có thể đoán signature từng byte bằng cách đo thời gian response.
     */
    public static function verify(string $payload, string $signature, string $secret): bool
    {
        $expected = hash_hmac('sha256', $payload, $secret);
        return hash_equals($expected, $signature);
    }
}

GitHub Signature

private function verifyGitHubSignature(Request $request): void
{
    $signature = $request->header('X-Hub-Signature-256');

    if (!$signature) {
        abort(403, 'Missing signature header');
    }

    $expected = 'sha256=' . hash_hmac(
        'sha256',
        $request->getContent(),
        config('services.github.webhook_secret'),
    );

    if (!hash_equals($expected, $signature)) {
        Log::warning('GitHub webhook signature mismatch', [
            'ip' => $request->ip(),
            'event' => $request->header('X-GitHub-Event'),
        ]);
        abort(403, 'Invalid signature');
    }
}

Shopify HMAC

private function verifyShopifySignature(Request $request): void
{
    $hmac = $request->header('X-Shopify-Hmac-Sha256');
    $expected = base64_encode(hash_hmac(
        'sha256',
        $request->getContent(),
        config('services.shopify.webhook_secret'),
        binary: true, // Shopify dùng raw binary trước khi base64
    ));

    if (!hash_equals($expected, $hmac ?? '')) {
        abort(403);
    }
}

Lưu ý: Mỗi provider có format signature khác nhau. GitHub prefix sha256=. Shopify dùng base64. Stripe có format riêng. Luôn đọc docs provider.

Abstraction: Multi-Provider Webhook Receiver

// app/Services/WebhookVerifier.php

class WebhookVerifier
{
    public function verify(string $provider, Request $request): void
    {
        match ($provider) {
            'stripe' => $this->verifyStripe($request),
            'github' => $this->verifyGitHub($request),
            'shopify' => $this->verifyShopify($request),
            default => abort(400, "Unknown webhook provider: {$provider}"),
        };
    }

    private function verifyStripe(Request $request): void { /* ... */ }
    private function verifyGitHub(Request $request): void { /* ... */ }
    private function verifyShopify(Request $request): void { /* ... */ }
}

Idempotency: Xử Lý Webhook Trùng Lặp

Webhook senders retry khi fail (timeout, 5xx). Stripe retry tối đa 3 ngày. Bạn PHẢI xử lý duplicates.

Migration

Schema::create('processed_webhooks', function (Blueprint $table) {
    $table->id();
    $table->string('provider', 50);
    $table->string('event_id');
    $table->string('event_type');
    $table->timestamp('processed_at');
    $table->unique(['provider', 'event_id']); // Prevent duplicates
});

Job Với Idempotency

class ProcessStripeWebhook implements ShouldQueue
{
    public int $tries = 3;
    public array $backoff = [10, 60, 300]; // 10s, 1m, 5m

    public function __construct(
        private array $payload,
        private string $eventType,
    ) {}

    public function handle(): void
    {
        $eventId = $this->payload['id'];

        // Atomic idempotency check — dùng unique constraint thay vì check-then-insert
        $wasInserted = ProcessedWebhook::insertOrIgnore([
            'provider' => 'stripe',
            'event_id' => $eventId,
            'event_type' => $this->eventType,
            'processed_at' => now(),
        ]);

        if ($wasInserted === 0) {
            Log::info("Duplicate webhook skipped: {$eventId}");
            return; // Đã xử lý trước đó
        }

        // Route đến handler phù hợp
        match ($this->eventType) {
            'payment_intent.succeeded' => $this->handlePaymentSuccess(),
            'customer.subscription.created' => $this->handleSubscriptionCreated(),
            'customer.subscription.deleted' => $this->handleSubscriptionCanceled(),
            'invoice.payment_failed' => $this->handlePaymentFailed(),
            'charge.refunded' => $this->handleRefund(),
            default => Log::info("Unhandled webhook type: {$this->eventType}"),
        };
    }

    private function handlePaymentSuccess(): void
    {
        $paymentIntent = $this->payload['data']['object'];
        $order = Order::where('payment_intent_id', $paymentIntent['id'])->first();

        if (!$order) {
            Log::warning('Order not found for payment intent', [
                'payment_intent_id' => $paymentIntent['id'],
            ]);
            return;
        }

        // Idempotent: kiểm tra trạng thái trước khi update
        if ($order->status === 'paid') {
            return; // Đã xử lý
        }

        $order->update(['status' => 'paid', 'paid_at' => now()]);
        $order->user->notify(new PaymentConfirmation($order));
    }

    private function handleSubscriptionCanceled(): void
    {
        $subscription = $this->payload['data']['object'];

        $user = User::where('stripe_customer_id', $subscription['customer'])->first();
        if (!$user) return;

        $user->subscription->update([
            'status' => 'canceled',
            'ends_at' => Carbon::createFromTimestamp($subscription['current_period_end']),
        ]);

        $user->notify(new SubscriptionCanceled());
    }

    /**
     * Xử lý failed jobs — tạo alert khi webhook processing fail.
     */
    public function failed(\Throwable $exception): void
    {
        Log::error('Webhook processing failed permanently', [
            'provider' => 'stripe',
            'event_type' => $this->eventType,
            'event_id' => $this->payload['id'] ?? 'unknown',
            'error' => $exception->getMessage(),
        ]);

        // Alert team
        Notification::route('slack', config('services.slack.webhook_url'))
            ->notify(new WebhookProcessingFailed('stripe', $this->eventType, $exception));
    }
}

Giải thích insertOrIgnore: Atomic check — database UNIQUE constraint ngăn duplicate inserts. Thread-safe, không cần locks. Tốt hơn pattern exists()create() (race condition possible).

Logging & Debugging

Lưu Raw Payload Cho Debug

// app/Models/WebhookLog.php
class WebhookLog extends Model
{
    protected $casts = ['payload' => 'array', 'headers' => 'array'];
}

// Trong controller
class StripeWebhookController extends Controller
{
    public function handle(Request $request): Response
    {
        $this->verifySignature($request);

        // Log toàn bộ payload — cực kỳ hữu ích khi debug
        WebhookLog::create([
            'provider' => 'stripe',
            'event_id' => $request->input('id'),
            'event_type' => $request->input('type'),
            'payload' => $request->all(),
            'headers' => collect($request->headers->all())->only([
                'stripe-signature', 'user-agent',
            ])->toArray(),
        ]);

        ProcessStripeWebhook::dispatch($request->all(), $request->input('type'));

        return response('OK', 200);
    }
}

Artisan Command Replay Webhook

Khi webhook processing fail và bạn đã fix bug, replay từ log:

class ReplayWebhookCommand extends Command
{
    protected $signature = 'webhook:replay {id}';

    public function handle(): int
    {
        $log = WebhookLog::findOrFail($this->argument('id'));

        // Xóa processed record để cho phép reprocess
        ProcessedWebhook::where('provider', $log->provider)
            ->where('event_id', $log->event_id)
            ->delete();

        // Re-dispatch
        match ($log->provider) {
            'stripe' => ProcessStripeWebhook::dispatch($log->payload, $log->event_type),
            'github' => ProcessGitHubWebhook::dispatch($log->payload, $log->event_type),
        };

        $this->info("Webhook {$log->event_id} replayed.");
        return Command::SUCCESS;
    }
}

Testing

class StripeWebhookTest extends TestCase
{
    use RefreshDatabase;

    public function test_valid_webhook_is_processed(): void
    {
        Queue::fake();

        $payload = ['id' => 'evt_123', 'type' => 'payment_intent.succeeded', 'data' => ['object' => []]];
        $signature = $this->generateStripeSignature($payload);

        $this->postJson('/webhooks/stripe', $payload, [
            'Stripe-Signature' => $signature,
        ])->assertOk();

        Queue::assertPushed(ProcessStripeWebhook::class, function ($job) {
            return $job->eventType === 'payment_intent.succeeded';
        });
    }

    public function test_invalid_signature_returns_403(): void
    {
        $this->postJson('/webhooks/stripe', ['data' => 'test'], [
            'Stripe-Signature' => 'invalid',
        ])->assertForbidden();
    }

    public function test_duplicate_webhook_is_skipped(): void
    {
        ProcessedWebhook::create([
            'provider' => 'stripe',
            'event_id' => 'evt_duplicate',
            'event_type' => 'payment_intent.succeeded',
            'processed_at' => now(),
        ]);

        $job = new ProcessStripeWebhook(
            payload: ['id' => 'evt_duplicate', 'data' => ['object' => []]],
            eventType: 'payment_intent.succeeded',
        );

        $job->handle();

        // Assert no order was updated (job returned early)
        $this->assertDatabaseCount('processed_webhooks', 1);
    }

    public function test_missing_signature_returns_403(): void
    {
        $this->postJson('/webhooks/stripe', ['data' => 'test'])
            ->assertForbidden();
    }

    private function generateStripeSignature(array $payload): string
    {
        $timestamp = time();
        $payloadJson = json_encode($payload);
        $signedPayload = "{$timestamp}.{$payloadJson}";
        $signature = hash_hmac('sha256', $signedPayload, config('services.stripe.webhook_secret'));
        return "t={$timestamp},v1={$signature}";
    }
}

Monitoring & Alerting

Prometheus Metrics (Nếu Dùng)

// Trong WebhookController
$this->metrics->increment('webhooks_received_total', ['provider' => 'stripe', 'type' => $request->input('type')]);

// Trong Job failed handler
$this->metrics->increment('webhooks_failed_total', ['provider' => 'stripe', 'type' => $this->eventType]);

Health Check

// Kiểm tra webhook processing health
Route::get('/health/webhooks', function () {
    $lastProcessed = ProcessedWebhook::latest('processed_at')->first();

    if (!$lastProcessed || $lastProcessed->processed_at->diffInHours(now()) > 24) {
        return response()->json(['status' => 'warning', 'message' => 'No webhooks in 24h'], 503);
    }

    $failedCount = WebhookLog::where('status', 'failed')
        ->where('created_at', '>', now()->subHour())
        ->count();

    if ($failedCount > 10) {
        return response()->json(['status' => 'critical', 'failed_count' => $failedCount], 503);
    }

    return response()->json(['status' => 'healthy']);
});

Security Checklist

✅ Verify signature trên MỌI webhook request
✅ Dùng hash_equals() (timing-attack safe)
✅ Log signature failures (detect attacks)
✅ Rate limit webhook endpoints
✅ Validate payload schema trước khi xử lý
✅ Lưu webhook secrets trong environment variables
✅ Rotate secrets định kỳ
✅ Không tin ngầm payload — validate mọi thứ
✅ IP allowlisting (nếu provider hỗ trợ)

Kết Luận

Webhook handling là hạ tầng quan trọng — sai một bước có thể mất dữ liệu hoặc gây lỗ hổng bảo mật.

Production checklist:

  1. Verify signatures — Đây là line of defense đầu tiên
  2. Trả 200 ngay lập tức — Trước khi xử lý, tránh timeout retry
  3. Xử lý trong background jobs — Queue cho async, retries tự động
  4. Implement idempotency — Track event IDs, dùng insertOrIgnore hoặc unique constraints
  5. Log mọi thứ — Raw payloads, processing results, failures
  6. Replay capability — Khi fix bug, cần replay webhooks đã fail
  7. Monitor — Alert khi failure rate tăng đột biến
  8. Test thoroughly — Valid/invalid signatures, duplicates, edge cases

Bình luận