vherbaut/laravel-inbound-webhooks

A Laravel package to handle inbound webhooks from any provider with signature validation, logging, and replay capabilities.

Installs: 0

Dependents: 0

Suggesters: 0

Security: 0

Stars: 0

Watchers: 0

Forks: 0

Open Issues: 0

pkg:composer/vherbaut/laravel-inbound-webhooks

1.0.1 2025-12-28 16:29 UTC

This package is auto-updated.

Last update: 2025-12-28 16:38:51 UTC


README

Tests PHPStan Latest Version on Packagist Total Downloads License PHP Version

A Laravel package to handle inbound webhooks from any provider with signature validation, logging, queue processing, and replay capabilities.

Features

  • 🔐 Signature validation for Stripe, GitHub, Slack, Twilio, and custom HMAC
  • 📦 Automatic storage of all incoming webhooks with full payload
  • Async processing via Laravel queues (responds 200 in < 100ms)
  • 🔄 Replay webhooks for debugging and development
  • 🗑️ Automatic cleanup with configurable retention
  • 📊 Event mapping to dispatch custom Laravel events
  • 🔌 Extensible - add your own drivers easily

Installation

composer require vherbaut/laravel-inbound-webhooks

Publish the config and migrations:

php artisan vendor:publish --tag=inbound-webhooks-config
php artisan vendor:publish --tag=inbound-webhooks-migrations
php artisan migrate

Quick Start

1. Configure your provider

Add your webhook secret to .env:

STRIPE_WEBHOOK_SECRET=whsec_xxxxx

2. Register your webhook URL

Point your provider's webhook settings to:

https://your-app.com/webhooks/stripe

3. Listen for events

In your EventServiceProvider or a listener:

use Vherbaut\InboundWebhooks\Events\WebhookReceived;

class EventServiceProvider extends ServiceProvider
{
    protected $listen = [
        WebhookReceived::class => [
            HandleStripeWebhook::class,
        ],
    ];
}
// app/Listeners/HandleStripeWebhook.php

class HandleStripeWebhook
{
    public function handle(WebhookReceived $event): void
    {
        if ($event->provider() !== 'stripe') {
            return;
        }

        match ($event->eventType()) {
            'payment_intent.succeeded' => $this->handlePaymentSucceeded($event),
            'customer.subscription.deleted' => $this->handleSubscriptionCanceled($event),
            default => null,
        };
    }

    protected function handlePaymentSucceeded(WebhookReceived $event): void
    {
        $paymentIntentId = $event->get('data.object.id');
        $amount = $event->get('data.object.amount');
        
        // Your logic here...
    }
}

Configuration

Providers

Configure each provider in config/inbound-webhooks.php:

'providers' => [
    'stripe' => [
        'driver' => 'stripe',
        'secret' => env('STRIPE_WEBHOOK_SECRET'),
        'tolerance' => 300, // Timestamp tolerance in seconds
    ],

    'github' => [
        'driver' => 'github',
        'secret' => env('GITHUB_WEBHOOK_SECRET'),
    ],

    'slack' => [
        'driver' => 'slack',
        'signing_secret' => env('SLACK_SIGNING_SECRET'),
    ],

    'twilio' => [
        'driver' => 'twilio',
        'auth_token' => env('TWILIO_AUTH_TOKEN'),
    ],

    // Custom provider using generic HMAC
    'acme' => [
        'driver' => 'hmac',
        'secret' => env('ACME_WEBHOOK_SECRET'),
        'algorithm' => 'sha256',
        'header' => 'X-Acme-Signature',
        'prefix' => 'sha256=', // Optional prefix
        'event_key' => 'event_type',
        'id_key' => 'webhook_id',
    ],
],

Event Mapping

Map specific webhook events to custom Laravel event classes:

'events' => [
    'stripe.payment_intent.succeeded' => \App\Events\PaymentReceived::class,
    'stripe.customer.subscription.deleted' => \App\Events\SubscriptionCanceled::class,
    'github.push' => \App\Events\GitHubPush::class,
],

Your custom event receives the webhook model:

// app/Events/PaymentReceived.php

class PaymentReceived
{
    public function __construct(
        public InboundWebhook $webhook
    ) {}
}

Events

The package dispatches three events during the webhook lifecycle:

WebhookReceived

Dispatched when a webhook is received and ready for processing. This is the primary event for handling webhooks.

use Vherbaut\InboundWebhooks\Events\WebhookReceived;

class HandleStripeWebhook
{
    public function handle(WebhookReceived $event): void
    {
        // Access webhook data via helper methods
        $provider = $event->provider();           // "stripe"
        $eventType = $event->eventType();         // "payment_intent.succeeded"
        $payload = $event->payload();             // Full payload array
        $value = $event->get('data.object.id');   // Dot notation access
    }
}

WebhookProcessed

Dispatched after a webhook has been successfully processed. Useful for metrics or cleanup.

use Vherbaut\InboundWebhooks\Events\WebhookProcessed;

class UpdateMetrics
{
    public function handle(WebhookProcessed $event): void
    {
        Metrics::increment("webhooks.{$event->webhook->provider}.success");
    }
}

WebhookFailed

Dispatched when webhook processing fails. Use for error notifications or logging.

use Vherbaut\InboundWebhooks\Events\WebhookFailed;

class NotifyOnFailure
{
    public function handle(WebhookFailed $event): void
    {
        Log::error('Webhook failed', [
            'provider' => $event->webhook->provider,
            'event_type' => $event->webhook->event_type,
            'error' => $event->exception->getMessage(),
        ]);

        // Send notification to Slack, email, etc.
    }
}

Registering Listeners

Register your listeners in EventServiceProvider:

use Vherbaut\InboundWebhooks\Events\WebhookReceived;
use Vherbaut\InboundWebhooks\Events\WebhookProcessed;
use Vherbaut\InboundWebhooks\Events\WebhookFailed;

protected $listen = [
    WebhookReceived::class => [
        HandleStripeWebhook::class,
        HandleGitHubWebhook::class,
    ],
    WebhookProcessed::class => [
        UpdateWebhookMetrics::class,
    ],
    WebhookFailed::class => [
        NotifyOnWebhookFailure::class,
        RetryFailedWebhook::class,
    ],
];

Queue Configuration

'queue' => [
    'connection' => env('INBOUND_WEBHOOKS_QUEUE_CONNECTION', 'default'),
    'queue' => env('INBOUND_WEBHOOKS_QUEUE', 'webhooks'),
],

Storage

'storage' => [
    'store_payload' => true,    // Store full payload (recommended for replay)
    'retention_days' => 30,     // Auto-prune after 30 days (null = forever)
],

Console Commands

Replay a webhook

# By UUID
php artisan webhooks:replay 550e8400-e29b-41d4-a716-446655440000

# Process synchronously (useful for debugging)
php artisan webhooks:replay 550e8400-e29b-41d4-a716-446655440000 --sync

# Force replay even if already processed
php artisan webhooks:replay 550e8400-e29b-41d4-a716-446655440000 --force

Prune old webhooks

# Use configured retention days
php artisan webhooks:prune

# Custom retention
php artisan webhooks:prune --days=7

# Only prune failed webhooks
php artisan webhooks:prune --status=failed

# Only prune specific provider
php artisan webhooks:prune --provider=stripe

# Dry run (see what would be deleted)
php artisan webhooks:prune --dry-run

Schedule automatic pruning in app/Console/Kernel.php:

$schedule->command('webhooks:prune')->daily();

Custom Drivers

Create custom drivers to integrate with any webhook provider not covered by the built-in drivers.

Registering a Custom Driver

Register your driver in the boot() method of a service provider:

// app/Providers/AppServiceProvider.php

use Vherbaut\InboundWebhooks\Facades\InboundWebhooks;
use App\Webhooks\Drivers\PayPalDriver;

public function boot(): void
{
    InboundWebhooks::extend('paypal', function (array $config) {
        return new PayPalDriver($config);
    });
}

Configuration

Add your provider configuration in config/inbound-webhooks.php:

'providers' => [
    'paypal' => [
        'driver' => 'paypal',                              // Must match the name used in extend()
        'webhook_id' => env('PAYPAL_WEBHOOK_ID'),          // Custom config keys
        'client_id' => env('PAYPAL_CLIENT_ID'),
        'client_secret' => env('PAYPAL_CLIENT_SECRET'),
        'sandbox' => env('PAYPAL_SANDBOX', true),
    ],
],

Creating the Driver Class

Extend AbstractDriver and implement the required methods from DriverInterface:

// app/Webhooks/Drivers/PayPalDriver.php

namespace App\Webhooks\Drivers;

use Illuminate\Http\Request;
use Vherbaut\InboundWebhooks\Drivers\AbstractDriver;
use Vherbaut\InboundWebhooks\Exceptions\InvalidSignatureException;

class PayPalDriver extends AbstractDriver
{
    /**
     * Validate the webhook signature.
     *
     * @throws InvalidSignatureException
     */
    public function validateSignature(Request $request): void
    {
        $transmissionId = $request->header('Paypal-Transmission-Id');
        $timestamp = $request->header('Paypal-Transmission-Time');
        $signature = $request->header('Paypal-Transmission-Sig');
        $certUrl = $request->header('Paypal-Cert-Url');

        if (! $transmissionId || ! $signature) {
            throw new InvalidSignatureException('Missing PayPal signature headers');
        }

        // Build the expected signature string
        $webhookId = $this->config['webhook_id'];
        $payload = $request->getContent();
        $crc32 = crc32($payload);

        $expectedSignature = "{$transmissionId}|{$timestamp}|{$webhookId}|{$crc32}";

        // Verify with PayPal certificate (simplified example)
        if (! $this->verifyWithCertificate($expectedSignature, $signature, $certUrl)) {
            throw new InvalidSignatureException('Invalid PayPal webhook signature');
        }
    }

    /**
     * Extract the event type from the webhook payload.
     */
    public function getEventType(Request $request): ?string
    {
        return $request->input('event_type');
    }

    /**
     * Extract the external ID (PayPal's webhook event ID).
     */
    public function getExternalId(Request $request): ?string
    {
        return $request->input('id');
    }

    /**
     * Define provider-specific headers to store for auditing.
     *
     * @return array<int, string>
     */
    protected function getRelevantHeaders(): array
    {
        return [
            'Content-Type',
            'Paypal-Transmission-Id',
            'Paypal-Transmission-Time',
            'Paypal-Transmission-Sig',
            'Paypal-Cert-Url',
        ];
    }

    private function verifyWithCertificate(
        string $data,
        string $signature,
        ?string $certUrl
    ): bool {
        // Your verification logic here
        return true;
    }
}

Using AbstractDriver Helpers

The AbstractDriver class provides utility methods for common signature validation patterns:

class AcmeDriver extends AbstractDriver
{
    public function validateSignature(Request $request): void
    {
        $signature = $request->header('X-Acme-Signature');
        $payload = $request->getContent();
        $secret = $this->config['secret'];

        if (! $signature) {
            throw new InvalidSignatureException('Missing signature header');
        }

        // Use built-in HMAC computation
        $expected = $this->computeHmac($payload, $secret, 'sha256');

        // Use timing-safe comparison to prevent timing attacks
        if (! $this->compareSignatures($expected, $signature)) {
            throw new InvalidSignatureException('Invalid signature');
        }
    }

    // ...
}

Available helper methods:

Method Description
computeHmac($payload, $secret, $algorithm) Compute HMAC signature (default: sha256)
compareSignatures($expected, $actual) Timing-safe string comparison
getRelevantHeaders() Override to define storable headers
getPayload($request) Override to customize payload extraction
getStorableHeaders($request) Filters headers based on getRelevantHeaders()

Custom Payload Extraction

Override getPayload() if your provider sends data in a non-standard format:

public function getPayload(Request $request): array
{
    // Handle form-encoded webhooks (e.g., Twilio)
    if ($request->isJson()) {
        return $request->json()->all();
    }

    return $request->all();
}

Full Driver Interface Reference

Your driver must implement all methods from DriverInterface:

interface DriverInterface
{
    /**
     * Validate the webhook signature.
     *
     * @throws InvalidSignatureException
     */
    public function validateSignature(Request $request): void;

    /**
     * Extract the event type from the webhook payload.
     */
    public function getEventType(Request $request): ?string;

    /**
     * Extract the external ID (provider's webhook/event ID).
     */
    public function getExternalId(Request $request): ?string;

    /**
     * Get the parsed payload from the request.
     */
    public function getPayload(Request $request): array;

    /**
     * Get headers that should be stored with the webhook.
     */
    public function getStorableHeaders(Request $request): array;
}

Testing Custom Drivers

use App\Webhooks\Drivers\PayPalDriver;
use Illuminate\Http\Request;
use Vherbaut\InboundWebhooks\Exceptions\InvalidSignatureException;

it('validates paypal webhook signature', function () {
    $driver = new PayPalDriver([
        'webhook_id' => 'WH-123',
        'client_id' => 'test',
        'client_secret' => 'secret',
    ]);

    $request = Request::create('/webhooks/paypal', 'POST', [], [], [], [
        'HTTP_PAYPAL_TRANSMISSION_ID' => 'abc123',
        'HTTP_PAYPAL_TRANSMISSION_SIG' => 'valid-signature',
        'HTTP_PAYPAL_TRANSMISSION_TIME' => '2024-01-15T10:00:00Z',
    ], json_encode(['event_type' => 'PAYMENT.CAPTURE.COMPLETED']));

    // Should not throw
    $driver->validateSignature($request);
});

it('rejects invalid signature', function () {
    $driver = new PayPalDriver(['webhook_id' => 'WH-123']);

    $request = Request::create('/webhooks/paypal', 'POST');

    $driver->validateSignature($request);
})->throws(InvalidSignatureException::class);

Querying Webhooks

use Vherbaut\InboundWebhooks\Models\InboundWebhook;

// Get all failed Stripe webhooks
$failed = InboundWebhook::provider('stripe')
    ->failed()
    ->latest()
    ->get();

// Get recent payment webhooks
$payments = InboundWebhook::provider('stripe')
    ->eventType('payment_intent.succeeded')
    ->where('created_at', '>', now()->subDay())
    ->get();

// Retry all failed webhooks
InboundWebhook::failed()->each(function ($webhook) {
    $webhook->resetForRetry();
    ProcessWebhook::dispatch($webhook);
});

Testing

In your tests, you can simulate incoming webhooks:

use Vherbaut\InboundWebhooks\Models\InboundWebhook;

// Create a webhook directly for testing
$webhook = InboundWebhook::create([
    'provider' => 'stripe',
    'event_type' => 'payment_intent.succeeded',
    'payload' => [
        'type' => 'payment_intent.succeeded',
        'data' => [
            'object' => [
                'id' => 'pi_123',
                'amount' => 1000,
            ],
        ],
    ],
]);

// Process it
ProcessWebhook::dispatchSync($webhook);

License

MIT