binary-cats/laravel-mailgun-webhooks
A Laravel package for receiving Mailgun webhooks: provides a webhook endpoint, verifies Mailgun signatures, and dispatches events for deliveries, opens, clicks, bounces, complaints, and unsubscribes so you can process email notifications cleanly.
1. Install and publish
composer require binary-cats/laravel-mailgun-webhooks
php artisan vendor:publish --tag=mailgun-webhooks-config
2. Set the signing key
Mailgun signs webhook payloads with an HTTP webhook signing key shown in the Mailgun dashboard (per domain, under Sending → Webhooks or Account settings). Put it in .env:
MAILGUN_WEBHOOK_SIGNING_KEY=key-xxxxxxxxxxxxxxxxxxxxxxxx
The package will use it to verify the signature block on each incoming request. Without it, verification either fails closed or is skipped depending on config — check the published config/mailgun-webhooks.php before going live.
3. Find the route
Run php artisan route:list | grep -i mailgun to confirm the endpoint the package registers (typically something like POST /mailgun/webhooks). You generally do not need to write a controller — the package receives the POST, verifies the signature, and dispatches an event.
4. Point Mailgun at it
In the Mailgun dashboard, set each webhook (delivered, permanent_fail, temporary_fail, opened, clicked, complained, unsubscribed) to your public URL, e.g. https://app.example.com/mailgun/webhooks. Mailgun sends one POST per event type, so a single endpoint is enough.
5. First use case: suppression on bounces/complaints
The highest-value starting point is stopping sends to dead or complaining addresses. Register a listener for the package’s webhook event, branch on the event type, and mark the address as suppressed:
// app/Providers/EventServiceProvider.php
protected $listen = [
\BinaryCats\MailgunWebhooks\Events\WebhookReceived::class => [
\App\Listeners\HandleMailgunWebhook::class,
],
];
public function handle($event): void
{
$data = $event->payload['event-data'] ?? $event->payload;
if (in_array($data['event'], ['failed', 'complained', 'unsubscribed'], true)) {
EmailSuppression::firstOrCreate([
'email' => strtolower($data['recipient']),
'reason' => $data['event'],
]);
}
}
Check the package README for the exact event class name — it may emit a single generic event, per-type events, or a WebhookStored-style event depending on version.
Listen generically, branch on event-data.event
Most teams register one listener and switch on $payload['event-data']['event'] (delivered, opened, clicked, failed, complained, unsubscribed). This keeps registration simple and makes it easy to add handling for new Mailgun event types later. If the package emits per-type events, prefer those — they’re more discoverable and let Laravel’s event discovery map them cleanly.
Persist the raw payload first, act second
Store every request in a mailgun_webhook_events table (event_id, event, recipient, timestamp, payload JSON, processed_at). This is your audit trail, replay source, and debugging aid when a listener misbehaves. It also lets you backfill state if your domain logic changes.
Make handling idempotent
Mailgun retries on non-2xx and can deliver duplicates. Guard on the event id or a hash of (timestamp, token, event, recipient):
if (MailgunWebhookEvent::where('mailgun_event_id', $data['id'])->exists()) {
return;
}
Do this inside a transaction or use a unique index so concurrent retries don’t double-process.
Queue slow work, acknowledge fast The webhook route should return 200 in milliseconds. Anything touching the DB heavily, sending mail, or calling external APIs belongs in a queued job:
public function handle($event): void
{
ProcessMailgunEvent::dispatch($event->payload)->onQueue('webhooks');
}
Set retry_after and worker timeouts appropriately; Mailgun’s own retry window is short relative to a backed-up queue.
Map events to domain state
delivered → mark message/recipient as delivered, clear “bouncing” flags.failed with severity: permanent → hard bounce, suppress forever.failed with severity: temporary → soft bounce, increment counter, suppress after N.complained → FBL complaint, suppress immediately and alert.unsubscribed → honor list-unsubscribe, suppress.opened / clicked → engagement metrics, not transactional logic (these are unreliable; image blocking, prefetch, bot clicks).Multi-domain setups
Mailgun’s signing key is per-domain (and regional endpoints differ: US vs EU). If you send from multiple domains, the package’s single-key config is the pain point. Options: run one app per domain, swap config('mailgun-webhooks.signing_key') early in a middleware based on the request host, or bypass the package’s verifier and implement signature checking yourself keyed by recipient domain. The signing formula is HMAC-SHA256(timestamp + token, signing_key) compared to signature.
Testing locally
Mailgun’s “Send test” button only works against a public URL, so use ngrok/expose or an artisan serve + tunnel. For automated tests, POST a fixture directly:
$this->postJson('/mailgun/webhooks', $fixture)
->assertOk();
$this->assertDatabaseHas('email_suppressions', ['email' => '[email protected]']);
If signature verification is enabled in tests, either compute a valid signature in a helper or disable verification in the test config. Don’t disable it in production.
Integration tips
Wire the package’s event into your existing notification/observability stack: log event-data.id and event-data['client-info'] so you can correlate with Mailgun’s own logs. For support workflows, expose the raw payload behind an admin route. For deliverability dashboards, project the events table into hourly aggregates rather than querying raw rows.
Signature verification
Mailgun signs timestamp + token (concatenated, in that order) with HMAC-SHA256 using the signing key. Any middleware that mutates or re-encodes the request body will break verification. Also reject stale timestamps — Mailgun recommends a tolerance window (e.g. ±15 minutes) to prevent replay. If you see “invalid signature” in logs, confirm you’re using the HTTP webhook signing key, not the API key, and that the domain matches the one Mailgun signed for.
Do not put the endpoint behind CSRF
If the package registers its route in the web middleware group, add it to VerifyCsrfToken::$except (Laravel 10 and below) or ensure it’s on api. Mailgun does not send a CSRF token, and a 419 is a silent killer because Mailgun will happily retry forever.
Config caching bites
After changing MAILGUN_WEBHOOK_SIGNING_KEY or the route prefix in .env, run php artisan config:clear (and config:cache in production). A stale cached config is the most common cause of “it worked yesterday.”
Payload shape is nested
Everything useful lives under event-data; the top level holds signature. Do not read $request->input('event') — read $request->input('event-data.event'). Fields like timestamp are strings, and event-data.id is only present for some event types. Guard with data_get() rather than direct array access.
Respond 2xx quickly and unconditionally Returning 4xx/5xx causes Mailgun to retry with backoff, then disable the webhook after repeated failures. If your listener throws, catch it, log it, store the payload, and still return 200 — process the failure asynchronously. Don’t let a listener exception propagate out of the package’s controller.
Ordering is not guaranteed
Opened/clicked events can arrive before delivered, and retries can reorder anything. Design state transitions to be commutative and idempotent. For analytics, trust the Mailgun timestamp, not arrival time.
Bounce semantics
failed events carry severity (permanent/temporary), reason, code, and message. Distinguish them — treating a temporary greylist as a hard bounce will suppress valid users. dropped is a separate event (Mailgun refused to send) and is easy to forget.
Recipient matching
Normalize (strtolower, trim) before lookup. Plus-addressing and aliases mean event-data.recipient may not equal your stored address; consider also storing event-data['envelope']['targets'] or the message headers if you need to reconcile to a user.
Multiple domains / EU region
Per-domain signing keys plus the api.eu.mailgun.net vs api.mailgun.net split can trip you up. The package likely assumes one key; plan an extension point (custom middleware, a wrapper route, or a small service that verifies manually) before adding a second sending domain.
Debugging checklist
route:list — is the route actually registered under the prefix you told Mailgun?config('mailgun-webhooks') in tinker — is the key present and cached correctly?storage/logs — did the request arrive at all? Log at the top of your listener.Extension points to keep open Subclass or replace the package’s controller if you need custom middleware (IP allowlisting, rate limiting, tenant resolution). Keep the package’s config file minimal — put domain logic in listeners, not in the published config, so upgrades are painless.
How can I help you explore Laravel packages today?