log in
consulting hosting industries the daily tools about contact

Resend Webhooks vs. Polling: The Edge Cases They Don't Document

Resend's webhook delivery looks clean until you hit out-of-order events and latency gaps in production. Here's what I learned the hard way.

Resend's webhook docs make delivery tracking look trivial. Subscribe to an endpoint, receive events, update your database. I shipped that exact setup for a healthcare client doing appointment reminders and watched it fall apart in subtle ways that took me two weeks to fully diagnose.

The short version: webhooks from Resend are mostly reliable, but "mostly" isn't a word you want in a system that tracks whether a patient got their pre-op instructions.

What Resend's Delivery Tracking Actually Does

Resend fires webhook events for the standard email lifecycle: email.sent, email.delivered, email.delivery_delayed, email.bounced, email.complained, email.opened, email.clicked. You register an endpoint, they POST a signed payload, you process it.

Polling is also available via the /emails/{id} endpoint, which returns a single email record with its current status. There's no bulk polling endpoint and no cursor-based list you can walk to catch up on a backlog. That asymmetry matters more than you'd think.

On paper, webhooks are the right call. Low latency, push-based, no polling loop to maintain. In practice, there are three edge cases the docs don't prepare you for.

Edge Case 1: Events Arrive Out of Order

I saw this on a Tuesday afternoon after a deployment. A batch of transactional emails went out, and a handful of records in my database showed delivered followed immediately by sent. The timestamps on the Resend payloads were correct - email.delivered had a later created_at than email.sent - but my webhook endpoint received email.delivered first.

This isn't unique to Resend. Any distributed webhook system can reorder events when retries, network hiccups, or internal queue partitioning are involved. But Resend's docs don't mention it at all, and the naive implementation - just write whatever status comes in - will corrupt your records.

The fix is to treat status as an ordered state machine and refuse to go backwards:

<?php

namespace App\Http\Controllers;

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

class ResendWebhookController extends Controller
{
    // Ordered lowest to highest. Never move backwards.
    const STATUS_ORDER = [
        'sent' => 10,
        'delivery_delayed' => 20,
        'delivered' => 30,
        'opened' => 40,
        'clicked' => 50,
        'bounced' => 60,
        'complained' => 70,
    ];

    public function handle(Request $request): \Illuminate\Http\Response
    {
        $this->verifySignature($request);

        $payload = $request->json()->all();
        $type    = $payload['type'] ?? null;
        $emailId = $payload['data']['email_id'] ?? null;

        if (!$type || !$emailId) {
            return response('Bad payload', 400);
        }

        // Strip the 'email.' prefix to get a bare status key.
        $newStatus = str_replace('email.', '', $type);

        $log = EmailLog::where('resend_id', $emailId)->first();

        if (!$log) {
            Log::warning('Resend webhook for unknown email', ['resend_id' => $emailId]);
            // Still 200 - Resend will retry on non-2xx and hammer you.
            return response('Unknown email', 200);
        }

        $currentRank = self::STATUS_ORDER[$log->status] ?? 0;
        $incomingRank = self::STATUS_ORDER[$newStatus] ?? 0;

        if ($incomingRank > $currentRank) {
            $log->update([
                'status'     => $newStatus,
                'updated_at' => now(),
            ]);
        } else {
            Log::info('Resend: ignoring out-of-order event', [
                'resend_id'  => $emailId,
                'current'    => $log->status,
                'incoming'   => $newStatus,
            ]);
        }

        return response('OK', 200);
    }

    private function verifySignature(Request $request): void
    {
        $signature = $request->header('svix-signature');
        $msgId     = $request->header('svix-id');
        $timestamp = $request->header('svix-timestamp');

        if (!$signature || !$msgId || !$timestamp) {
            abort(401, 'Missing signature headers');
        }

        $secret  = config('services.resend.webhook_secret');
        $body    = $request->getContent();
        $toSign  = "{$msgId}.{$timestamp}.{$body}";
        $hmac    = hash_hmac('sha256', $toSign, base64_decode(explode('_', $secret)[1]));
        $expected = "v1," . base64_encode(hex2bin($hmac));

        // svix-signature can contain multiple space-separated v1,<sig> values.
        $provided = explode(' ', $signature);

        foreach ($provided as $candidate) {
            if (hash_equals($expected, trim($candidate))) {
                return;
            }
        }

        abort(401, 'Invalid signature');
    }
}

The STATUS_ORDER map is the key piece. Bounced and complained sit at the top because once an address hard-bounces, I don't want an out-of-order delivered from a previous attempt overwriting that.

Edge Case 2: Webhook Latency Can Be Minutes, Not Seconds

Resend uses Svix under the hood for webhook delivery. Svix is solid, but under load - or if your endpoint returns a non-2xx and triggers retries - you can see multi-minute delays between the actual email event and the webhook arriving at your server.

I measured this on the healthcare project. During a batch of about 400 appointment reminder emails, median webhook latency was around 4 seconds. Fine. But the 99th percentile was 3.5 minutes, and two webhooks from that batch arrived over 11 minutes late because my endpoint had a brief 502 during a deploy window and Svix backed off before retrying.

For appointment reminders where staff are watching a dashboard in near-real-time, 11 minutes of stale status is a problem.

My hybrid solution: poll on-demand for anything time-sensitive, and let webhooks handle the eventual consistency for everything else.

<?php

namespace App\Services;

use App\Models\EmailLog;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;

class ResendStatusService
{
    private string $apiKey;

    public function __construct()
    {
        $this->apiKey = config('services.resend.api_key');
    }

    /**
     * Poll Resend directly for current status.
     * Use when you need fresh data and can't wait for a webhook.
     */
    public function pollStatus(EmailLog $log): ?string
    {
        if (!$log->resend_id) {
            return null;
        }

        $response = Http::withToken($this->apiKey)
            ->get("https://api.resend.com/emails/{$log->resend_id}");

        if (!$response->successful()) {
            Log::warning('Resend poll failed', [
                'resend_id' => $log->resend_id,
                'status'    => $response->status(),
            ]);
            return null;
        }

        $data      = $response->json();
        $newStatus = $data['last_event'] ?? null;

        if ($newStatus && $newStatus !== $log->status) {
            $log->update(['status' => $newStatus]);
        }

        return $newStatus;
    }
}

I wire this into a Livewire component so staff can click a "refresh status" button without me hammering the API on every page load. Rate limits on the Resend API are reasonable - 2 requests per second on the free tier, higher on paid - so manual on-demand polling is fine. An automated loop polling every email every 30 seconds is not.

Edge Case 3: No Bulk Reconciliation Endpoint

This is the one that really frustrated me. After that deploy window with the 502s, I had roughly 30 emails sitting in sent status that had almost certainly delivered. I wanted to reconcile them in bulk. There is no way to do this with the Resend API.

You can call /emails/{id} one at a time. There's no /emails?status=sent&after=cursor endpoint. So reconciliation means looping your own database records and hitting the API once per email, which you need to rate-limit yourself.

<?php

namespace App\Console\Commands;

use App\Models\EmailLog;
use App\Services\ResendStatusService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\RateLimiter;

class ReconcileResendStatuses extends Command
{
    protected $signature   = 'resend:reconcile {--hours=2 : Look back this many hours}';
    protected $description = 'Reconcile stuck email statuses against Resend API';

    public function handle(ResendStatusService $resend): int
    {
        $cutoff = now()->subHours((int) $this->option('hours'));

        // Only look at emails that are stuck in early statuses
        // and haven't been updated recently.
        $stuck = EmailLog::whereIn('status', ['sent', 'delivery_delayed'])
            ->where('updated_at', '<', $cutoff)
            ->whereNotNull('resend_id')
            ->limit(200)
            ->get();

        $this->info("Found {$stuck->count()} emails to reconcile.");

        foreach ($stuck as $log) {
            // Stay under 2 req/sec with a simple sleep.
            // For production volume, use a queued job with rate limiting.
            usleep(600_000); // 0.6 seconds

            $status = $resend->pollStatus($log);
            $this->line("{$log->resend_id}: {$status}");
        }

        return Command::SUCCESS;
    }
}

I schedule this to run every 30 minutes. It's not elegant, but it patches the gap that the missing bulk endpoint creates.

When I'd Reach for Webhooks, When I Wouldn't

Webhooks are the right default for Resend. They're low-overhead, reasonably fast under normal conditions, and the Svix signing is easy to verify. For a standard transactional email flow where you're just logging delivery status and suppressing bounces, webhooks plus the state-machine guard above is all you need.

I'd add polling when:

  • You have a UI that shows live status and users care about freshness (appointment reminders, order confirmations, etc.)
  • You've had a webhook outage and need to reconcile a backlog
  • You're in early development and don't want to set up ngrok or a tunnel just to test delivery status

I would not build a system that polls exclusively. Without webhooks you're either hammering the API or accepting stale data on a timer. The hybrid approach - webhooks for eventual consistency, polling for time-sensitive or reconciliation use cases - is the right architecture.

One thing I'd genuinely like to see from Resend: a cursor-based list endpoint for email status. Svix exposes something like this for webhook attempts; Resend should expose it for email events. Until they do, the reconciliation loop is just part of the job.

The Bottom Line

Resend is one of the better transactional email APIs I've worked with - the DX is clean and the deliverability is solid. But the webhook docs treat the happy path as the whole story, and in production, the happy path is a minority of your volume. Guard against out-of-order events, plan for multi-minute webhook latency under load, and accept that you're going to need a polling reconciliation job as a backstop. None of that is hard once you know to look for it.

Related

Need help shipping something like this? Get in touch.