Mailgun Webhooks in Laravel

Mailgun Webhooks in Laravel

This guide explains how to receive Mailgun webhooks in Laravel, tolerate form-encoded and JSON payloads, enqueue processing, and forward normalized events to SendPromptly. You’ll get copy‑paste routes and controllers and a suggested idempotency approach.

The primary keyword mailgun webhooks laravel is used throughout the examples and test steps.

Mailgun webhook types you’ll actually use

Not all Mailgun events matter for every app — choose the set that matches your product needs.

Delivery-related: delivered, bounced, dropped. Engagement: opened, clicked. Handle delivery events for billing/blacklist logic; handle engagement for analytics.

Endpoint strategy (one endpoint vs per-event endpoints)

You can use a single endpoint and dispatch on event or provide separate endpoints for higher isolation.

Micro checklist:

  • Pick either single endpoint or per-event endpoints deliberately.
  • Persist raw payload for auditing.
  • Queue everything and return 204.

Configure Mailgun

Add webhook URLs in the Mailgun dashboard and use a dedicated signing key for verification.

Add webhook URLs in Mailgun dashboard

Point Mailgun webhooks to your HTTPS endpoint(s) and enable the events you intend to consume.

Use a dedicated “webhooks signing key” (don’t reuse secrets randomly)

Store the signing key separately from your API keys and rotate it regularly.

Micro checklist:

  • Confirm webhook URLs in staging before switching production.
  • Use the signing key shown in Mailgun dashboard, not the global API key.

Laravel endpoint

Accept both form-encoded and JSON payloads, record the raw body, and enqueue work.

Accept form-encoded or JSON payloads

Mailgun may send application/x-www-form-urlencoded; normalize to an array in your controller.

Store raw payload for debugging

Persist raw body to webhook_inbox to reproduce issues later.

ACK fast + queue work

Enqueue a job and return 204 immediately to stop Mailgun retries.

Required code snippets

A) Route

1
Route::post('/webhooks/mailgun/events', [MailgunWebhookController::class, 'handle']);

B) Controller (payload-tolerant)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
<?php

namespace App\Http\Controllers\Webhooks;

use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use App\Jobs\ProcessMailgunEvent;

class MailgunWebhookController extends Controller
{
    public function handle(Request $request)
    {
        // Mailgun can send as form-data; normalize to array.
        $payload = $request->all();

        ProcessMailgunEvent::dispatch($payload);

        return response()->noContent(204);
    }
}

Normalize + forward into SendPromptly

Normalize Mailgun fields into your canonical event schema and forward with an Idempotency-Key.

Canonical schema fields

Include provider, provider_event, occurred_at, recipient, message_id, and meta.

Idempotency key strategy (provider message id + event + timestamp)

Combine Mailgun message id, event type, and timestamp into a stable idempotency key for ingestion.

Common gotcha: Treating Mailgun webhooks as JSON-only will break form-encoded deliveries.

Test steps (curl + expected response)

1
2
3
4
5
curl -i -X POST "https://YOUR-APP.test/api/webhooks/mailgun/events" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  --data-urlencode 'event=delivered' \
  --data-urlencode 'timestamp=1700000000' \
  --data-urlencode '[email protected]'

Expected: HTTP/1.1 204 No Content

Common failure modes

  1. Treating every webhook as JSON-only (but receiving form-encoded).
  2. Doing heavy work inline → timeouts → re-delivery.
  3. No dedupe → double counting opens/clicks.
  4. Not persisting raw payload → painful debugging.
  5. Shipping without signature verification (fix via the next guide).

Prove your pipeline works: trigger one Mailgun-style event, forward it, and inspect it. Open Sample Project · Open Message Log

Conclusion

  • Accept form-encoded and JSON payloads and normalize early.
  • ACK fast and queue processing to avoid retries.
  • Use provider message id + event + timestamp for idempotency.
  • Persist raw payloads for reliable debugging.