Receiver is a drop-in webhook handling library for Laravel.
Receiver gives you a consistent, expressive way to receive, verify, and handle incoming webhooks in your Laravel app. Point a route at a controller, call three methods, and you're done.
Out of the box, Receiver supports:
| Provider | Driver |
|---|---|
| GitHub | github |
| HubSpot | hubspot |
| Mailchimp Marketing | mailchimp |
| Paddle Billing | paddle |
| Postmark | postmark |
| SendGrid Events | sendgrid |
| Shopify | shopify |
| Slack Events API | slack |
| Stripe | stripe |
| Twilio | twilio |
Any other webhook source can be added with a custom provider.
- Installation
- Configuration
- Receiving Webhooks
- Handling Webhooks
- Extending Receiver
- Share Your Receivers!
- Credits
- License
Requires PHP ^8.2 and Laravel 10+.
composer require hotmeteor/receiverNote: The Stripe provider requires
stripe/stripe-php:composer require stripe/stripe-php
Each provider reads its secret from config/services.php. Add an entry for each source you intend to receive from.
Most providers use the same shape:
'github' => ['webhook_secret' => env('GITHUB_WEBHOOK_SECRET')],
'hubspot' => ['webhook_secret' => env('HUBSPOT_WEBHOOK_SECRET')],
'paddle' => ['webhook_secret' => env('PADDLE_WEBHOOK_SECRET')],
'shopify' => ['webhook_secret' => env('SHOPIFY_WEBHOOK_SECRET')],
'slack' => ['webhook_secret' => env('SLACK_WEBHOOK_SECRET')],
'stripe' => ['webhook_secret' => env('STRIPE_WEBHOOK_SECRET')],
'twilio' => ['webhook_secret' => env('TWILIO_AUTH_TOKEN')],Mailchimp — Mailchimp Marketing webhooks are verified via a secret you embed in your webhook URL (?secret=...). Configure the same value here so Receiver can compare it:
'mailchimp' => ['webhook_secret' => env('MAILCHIMP_WEBHOOK_SECRET')],SendGrid — Signature verification is opt-in. Set webhook_secret to the PEM-format public key found in the SendGrid dashboard under Settings → Mail Settings → Event Webhook. Leave it empty to accept all requests without verification.
'sendgrid' => ['webhook_secret' => env('SENDGRID_WEBHOOK_PUBLIC_KEY', '')],Postmark — Postmark supports several verification strategies. Configure which ones to use under the webhook key:
'postmark' => [
'token' => env('POSTMARK_TOKEN'),
'webhook' => [
// One or more of: 'auth', 'headers', 'ips'
'verification_types' => ['headers', 'ips'],
// Header name => expected value pairs (used with 'headers')
'headers' => [
'X-Custom-Header' => env('POSTMARK_WEBHOOK_HEADER'),
],
// Allowed source IPs (used with 'ips')
// https://postmarkapp.com/support/article/800-ips-for-firewalls#webhooks
'ips' => [
'3.134.147.250',
'50.31.156.6',
'50.31.156.77',
'18.217.206.57',
],
],
],Postmark verification_type |
Description |
|---|---|
auth |
HTTP Basic Auth via Auth::onceBasic() |
headers |
Validates that specific request headers match expected values |
ips |
Validates that the request originates from an allowed IP |
If verification_types is empty or not set, all Postmark requests are accepted without verification.
Create a controller and route for each webhook source, then call driver(), receive(), and ok():
<?php
namespace App\Http\Controllers\Webhooks;
use Illuminate\Http\Request;
use Receiver\Facades\Receiver;
class StripeWebhookController extends Controller
{
public function store(Request $request)
{
return Receiver::driver('stripe')
->receive($request)
->ok();
}
}driver()— selects the provider and reads its configreceive()— verifies the signature and maps the eventok()— dispatches matched handlers and returns a200response
If you'd rather handle all webhooks through a single controller, use a {provider} route parameter:
// routes/web.php
Route::post('/webhooks/{provider}', [WebhookController::class, 'store'])
->withoutMiddleware(\Illuminate\Foundation\Http\Middleware\ValidateCsrfToken::class);<?php
namespace App\Http\Controllers\Webhooks;
use Illuminate\Http\Request;
use Receiver\Facades\Receiver;
class WebhookController extends Controller
{
public function store(Request $request, string $provider)
{
return Receiver::driver($provider)
->receive($request)
->ok();
}
}Or use the included ReceivesWebhooks trait, which provides this exact store() method for you:
<?php
namespace App\Http\Controllers\Webhooks;
use Receiver\ReceivesWebhooks;
class WebhookController extends Controller
{
use ReceivesWebhooks;
}Receiver silently ignores events it has no handler for. If you'd like to do something with unhandled events, add a fallback() callback before ok():
use Receiver\Providers\Webhook;
return Receiver::driver($provider)
->receive($request)
->fallback(function (Webhook $webhook) {
Log::info('Unhandled webhook', ['event' => $webhook->getEvent()]);
})
->ok();Once a webhook is received, Receiver looks for a handler class that matches the event and dispatches it. Handlers live in App\Http\Handlers\{Driver}\ by default. If no matching handler is found the webhook is silently ignored and a 200 is returned.
The handler class name is derived from the event name — all non-alphanumeric characters are treated as word separators, then converted to StudlyCase:
| Event name | Handler class |
|---|---|
customer.created |
CustomerCreated |
subscription_activated |
SubscriptionActivated |
orders_created |
OrdersCreated |
invoice.payment_failed |
InvoicePaymentFailed |
For example, Stripe's customer.created event dispatches App\Http\Handlers\Stripe\CustomerCreated.
Each handler receives the $event name and the $data array, and must use the Dispatchable trait:
<?php
namespace App\Http\Handlers\Stripe;
use Illuminate\Foundation\Bus\Dispatchable;
class CustomerCreated
{
use Dispatchable;
public function __construct(
public string $event,
public array $data,
) {}
public function handle(): void
{
// Your code here
}
}Because Receiver calls dispatch() on each handler, making a handler queued is as simple as implementing ShouldQueue:
<?php
namespace App\Http\Handlers\Stripe;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class CustomerCreated implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct(
public string $event,
public array $data,
) {}
public function handle(): void
{
// Your code here
}
}See the Laravel queue documentation for full details.
A provider is a PHP class that tells Receiver how to extract the event name, the payload data, and optionally how to verify the request's authenticity.
The quickest way to scaffold a new provider is with the receiver:make Artisan command:
# Basic provider
php artisan receiver:make Mailgun
# With signature verification scaffolded
php artisan receiver:make Mailgun --verifiedThe generated class is placed in App\Http\Receivers. Once created, register the driver in your AppServiceProvider:
public function boot(): void
{
app('receiver')->extend('mailgun', function () {
return new \App\Http\Receivers\MailgunProvider(
config('services.mailgun.webhook_secret')
);
});
}Implement getEvent() to return the event name. Optionally implement getData() to return the event payload — by default it returns $request->all().
<?php
namespace App\Http\Receivers;
use Illuminate\Http\Request;
use Receiver\Providers\AbstractProvider;
class MailgunProvider extends AbstractProvider
{
public function getEvent(Request $request): string|array
{
return $request->input('event-data.event');
}
public function getData(Request $request): array
{
return $request->input('event-data', []);
}
}Implement a verify() method that returns true if the request is authentic, or false to reject it with a 401 response:
public function verify(Request $request): bool
{
$signature = $request->header('X-Mailgun-Signature');
$expected = hash_hmac('sha256', $request->getContent(), $this->secret);
return hash_equals($expected, (string) $signature);
}The signing secret from config/services.{driver}.webhook_secret is available as $this->secret.
Some services send a verification request when a webhook URL is first registered. Implement handshake() to respond to it:
public function handshake(Request $request): array
{
return ['challenge' => $request->input('challenge')];
}When handshake() returns a non-empty array, Receiver responds immediately with that payload and skips normal event handling. When it returns an empty array, Receiver processes the request normally.
Some services batch multiple events into a single request. Return an ['event_name' => $eventData] array from getEvent() and Receiver will dispatch a separate handler for each entry:
public function getEvent(Request $request): string|array
{
$events = [];
foreach (json_decode($request->getContent(), true) as $event) {
$type = $event['type'] ?? null;
if ($type && ! isset($events[$type])) {
$events[$type] = $event;
}
}
return $events;
}If you're building a reusable provider package to share, add the --provider flag to also generate a companion ServiceProvider that registers the driver automatically:
php artisan receiver:make Mailgun --provider
php artisan receiver:make Mailgun --verified --providerThis generates:
app/Http/Receivers/MailgunProvider.php— your provider classapp/Providers/MailgunReceiverServiceProvider.php— auto-registers the driver viaReceiver::extend()
To support Laravel package auto-discovery, add the service provider to your package's composer.json:
{
"extra": {
"laravel": {
"providers": [
"YourVendor\\YourPackage\\MailgunReceiverServiceProvider"
]
}
}
}Users who install your package will have the driver available immediately, with no manual registration required.
Built a provider for a service not listed above? Share it with the community in the Receivers Discussion topic!
Made with contributors-img.
The MIT License (MIT). Please see License File for more information.

