log in
consulting hosting industries the daily tools about contact

Mailgun's Log Window Will Burn You Mid-Investigation

Mailgun only keeps event logs for 30 days on most plans. When a deliverability problem surfaces, that window is usually already half-gone.

Mailgun's event log retention is 30 days on the Foundation plan and shorter on some legacy plans. That sounds fine until you're sitting across from a client on day 32 trying to figure out why their invoice emails have been bouncing for six weeks. I've been in that exact seat, and it's not a fun place to be.

The Problem Deliverability Investigations Actually Have

Deliverability problems are almost never reported in real time. A sales rep notices a prospect "never got" a quote. A clinic coordinator says patients have been calling in confused because they didn't get appointment confirmations. A warehouse manager says suppliers stopped responding to POs two months ago. By the time the ticket lands in front of me, the events that would explain the failure are often gone.

Mailgun's Events API is genuinely good when you have data to query. You can filter by recipient, event type, tags, delivery status - it's all there. But if the event aged out of their retention window, you're done. There's no escalation path, no archive tier you can purchase retroactively. The data is gone.

The fix is simple: stream Mailgun's webhook events into your own storage in real time, and keep them as long as you need. Here's how I wire this up in Laravel.

Receiving and Persisting Mailgun Webhook Events

First, the database table. I keep it lean - store the full payload as JSON so I never have to worry about schema changes when Mailgun adds fields.

// database/migrations/2024_01_15_000000_create_mailgun_events_table.php

public function up(): void
{
    Schema::create('mailgun_events', function (Blueprint $table) {
        $table->id();
        $table->string('mg_event_id')->unique()->index();
        $table->string('event_type', 64)->index();
        $table->string('recipient')->nullable()->index();
        $table->string('message_id')->nullable()->index();
        $table->string('tag')->nullable()->index();
        $table->integer('timestamp')->index(); // unix timestamp from Mailgun
        $table->json('payload');               // full event blob
        $table->timestamps();
    });
}

Now the webhook controller. Mailgun signs every request with HMAC-SHA256 so you can verify authenticity before trusting anything.

<?php

namespace App\Http\Controllers;

use App\Models\MailgunEvent;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Log;

class MailgunWebhookController extends Controller
{
    public function handle(Request $request): \Illuminate\Http\JsonResponse
    {
        if (! $this->verifySignature($request)) {
            Log::warning('Mailgun webhook signature mismatch', [
                'ip' => $request->ip(),
            ]);
            return response()->json(['error' => 'invalid signature'], 403);
        }

        $data        = $request->input('event-data', []);
        $signature   = $request->input('signature', []);
        $mgEventId   = $data['id'] ?? null;

        if (! $mgEventId) {
            return response()->json(['ok' => true]); // ignore malformed
        }

        // Idempotent upsert - Mailgun can deliver the same event more than once
        MailgunEvent::updateOrCreate(
            ['mg_event_id' => $mgEventId],
            [
                'event_type' => $data['event']             ?? 'unknown',
                'recipient'  => $data['recipient']         ?? null,
                'message_id' => $data['message']['headers']['message-id'] ?? null,
                'tag'        => $data['tags'][0]           ?? null,
                'timestamp'  => $signature['timestamp']    ?? time(),
                'payload'    => $data,
            ]
        );

        return response()->json(['ok' => true]);
    }

    private function verifySignature(Request $request): bool
    {
        $sig       = $request->input('signature', []);
        $token     = $sig['token']     ?? '';
        $timestamp = $sig['timestamp'] ?? '';
        $signature = $sig['signature'] ?? '';

        $key  = config('services.mailgun.webhook_signing_key');
        $hash = hash_hmac('sha256', $timestamp . $token, $key);

        return hash_equals($hash, $signature);
    }
}

Register it in routes/api.php and exclude it from CSRF middleware:

Route::post('/webhooks/mailgun', [MailgunWebhookController::class, 'handle'])
    ->name('webhooks.mailgun');

In Mailgun's dashboard, subscribe to all event types: delivered, failed, bounced, complained, unsubscribed, opened, clicked. You want everything. Storage is cheap; debugging blind is expensive.

The Gotchas That Bit Me

The signing key is not your API key. There's a separate "HTTP webhook signing key" in Mailgun's dashboard under Sending -> Webhooks. I've seen developers grab the API key, wonder why verification always fails, and then disable verification entirely. Don't do that. Find the right key.

Mailgun will retry delivery of webhooks if your endpoint returns anything other than a 2xx. That means you can get duplicate events. The updateOrCreate on mg_event_id above handles this, but only if you have a unique index on that column. Without it, under any kind of load you'll get race conditions and duplicate rows.

The message-id field is nested weirdly. It lives at event-data.message.headers.message-id - not at the top level where you'd expect it. And it includes the angle brackets from the RFC 5322 header, so you'll see <abc123@mg.yourdomain.com>. Strip those if you want to match it against what you stored when sending.

Timestamps are Unix integers, not ISO strings. I've seen code that calls Carbon::parse() on the integer and then wonders why dates are wrong. Use Carbon::createFromTimestamp() or just store the integer and format it on output.

Soft bounces vs. hard bounces matter. Mailgun uses severity field values of temporary and permanent within failed/bounced events. If you're building any kind of suppression logic - and you should be - you need to check severity before nuking an address from your send list. I filter my investigations by this constantly.

Querying Your Own Logs During an Investigation

Once you have data flowing into the table, investigating a deliverability problem becomes a Eloquent query instead of a prayer:

// All failures for a domain in the last 90 days
$failures = MailgunEvent::where('event_type', 'failed')
    ->where('recipient', 'like', '%@problematic-domain.com')
    ->where('timestamp', '>=', now()->subDays(90)->timestamp)
    ->orderByDesc('timestamp')
    ->get();

// Pull the full payload to see SMTP response codes
foreach ($failures as $event) {
    $deliveryStatus = $event->payload['delivery-status'] ?? [];
    $code    = $deliveryStatus['code']    ?? 'n/a';
    $message = $deliveryStatus['message'] ?? 'n/a';
    echo "{$event->recipient} - {$code} - {$message}\n";
}

That delivery-status.message field is gold. It's the raw SMTP response from the receiving server - which is usually explicit about whether you're being blocked for IP reputation, SPF/DKIM failures, content filtering, or just a full mailbox.

When I'd Reach for This Pattern

I do this for every Mailgun integration I ship, full stop. The cost is an afternoon of setup and a few hundred megabytes of database storage per year for a typical SaaS application. The payoff is never having to tell a client "that data doesn't exist anymore."

I'd keep the data for at least 180 days. If you're in healthcare or any regulated space, check with your compliance person - you may need longer, and you may need to think about where this data lives relative to your BAA coverage.

If you're on Mailgun's Scale or Custom plan you get longer retention from them directly - I believe up to a year in some configurations. I still buffer locally anyway because I want the data inside my stack where I can join it against my own application tables. "What percentage of invoices sent last quarter had a deliverability failure?" is a query I can answer in seconds with local data. I can't answer it through Mailgun's dashboard at all.

The one situation where I'd think twice: if you're sending millions of emails per month, the event volume gets large fast. At that scale I'd ship events to something like a time-series table in Postgres with a partitioning strategy, or push to an S3-based archive rather than a plain relational table. But for the kind of apps I typically build - tens of thousands of messages per month - a standard MySQL or Postgres table with the right indexes handles it without breaking a sweat.

The Closing Take

Mailgun's Events API is solid, but it's a view into their data, not yours. The moment you start depending on it for auditability, you're renting visibility you can lose at any time. Spend the afternoon, own your logs, and stop sweating the retention window.

Need help shipping something like this? Get in touch.