Webhooks vs Polling for Email Events: Why Webhooks Win | DexcyJet Blog

Webhooks vs Polling for Email Events: Why Webhooks Win

Webhooks vs polling for email delivery events — the technical case for webhooks, HMAC-SHA256 signature verification, retry strategies, and the Elixir pattern DexcyJet uses internally.

AR

Aakash Rao

Founding Engineer · March 09, 2026 · 8 min read

Webhooks vs Polling for Email Events: Why Webhooks Win

Webhooks vs polling is a classic API design debate, but in the context of email delivery events — opens, clicks, bounces, complaints, unsubscribes — the stakes are higher than in most domains. Email events are time-sensitive. A bounce that isn’t suppressed within minutes gets sent to again. A spam complaint that isn’t processed within hours affects your sending reputation. A click event that isn’t captured in real time breaks analytics.

This post makes the technical case for webhooks, covers HMAC signature verification in detail, and describes DexcyJet’s own webhook infrastructure including retry strategy.

The Problem with Polling

Polling means periodically calling an API endpoint to check for new events: “Are there any new bounces since my last check?”

# A polling approach
defmodule EmailEventPoller do
  use GenServer

  def handle_info(:poll, state) do
    {:ok, events} = DexcyJet.Client.get_events(since: state.last_polled_at)
    process_events(events)
    Process.send_after(self(), :poll, 60_000)  # Poll every 60 seconds
    {:noreply, %{state | last_polled_at: DateTime.utc_now()}}
  end
end

Problems with this pattern

Latency: A 60-second poll interval means events are processed up to 60 seconds after they occur. For a bounce, that means potentially sending to the same address again before the bounce is suppressed. For a spam complaint, it means 60 seconds of reputation impact accumulates before you react.

Rate limits: Polling generates API calls proportional to your check frequency, not proportional to actual event volume. At 1-minute intervals, that’s 1,440 API calls per day regardless of whether any events occurred. At scale, this burns through rate limits on low-activity periods while potentially missing events during high-activity bursts if the event list is paginated.

Resource waste: Your server is making network calls, parsing responses, and running database writes on a fixed schedule regardless of whether anything happened.

Gap risk: If your poller goes down for maintenance or crashes, events that occurred during the gap are potentially missed — depending on whether the API supports reliable “since” pagination and whether you’re handling cursor management correctly.

How Webhooks Solve These Problems

With webhooks, the event source (DexcyJet) sends an HTTP POST to your endpoint immediately when an event occurs. Your server processes it in real time and responds with 2xx to acknowledge receipt.

Email event occurs (bounce, open, click)
        ↓
DexcyJet fires HTTP POST to your webhook URL
        ↓
Your server receives, verifies signature, processes event
        ↓
200 OK response → event acknowledged
  • Latency: Sub-second from event to your system
  • No rate limit waste: HTTP calls only happen when events occur
  • Reliable: DexcyJet retries failed webhook deliveries (more on this below)
  • Simpler application logic: No polling state to manage, no cursor tracking

HMAC-SHA256 Signature Verification

The security concern with webhooks: your endpoint is publicly accessible (by definition — it needs to receive HTTP requests from DexcyJet’s infrastructure). How do you know that a POST to your webhook endpoint actually came from DexcyJet and hasn’t been tampered with?

DexcyJet signs every webhook request with HMAC-SHA256.

How it works

For each webhook delivery, DexcyJet:

  1. Takes the raw request body (as bytes)
  2. Computes HMAC-SHA256(body, your_webhook_secret)
  3. Includes the result as a hex string in the X-DexcyJet-Signature header

On your end, you verify:

defmodule MyAppWeb.WebhookController do
  use MyAppWeb, :controller

  @webhook_secret System.get_env("DEXCYJET_WEBHOOK_SECRET")

  def receive(conn, _params) do
    raw_body = conn.assigns[:raw_body]
    signature = get_req_header(conn, "x-dexcyjet-signature") |> List.first()

    expected_signature =
      :crypto.mac(:hmac, :sha256, @webhook_secret, raw_body)
      |> Base.encode16(case: :lower)

    if Plug.Crypto.secure_compare(expected_signature, signature) do
      payload = Jason.decode!(raw_body)
      process_event(payload)
      send_resp(conn, 200, "ok")
    else
      send_resp(conn, 401, "invalid signature")
    end
  end
end

Important: use Plug.Crypto.secure_compare/2 (or equivalent) instead of == for the comparison. String equality with == is vulnerable to timing attacks. Constant-time comparison is required.

Capturing the raw body in Plug

You need the raw (un-parsed) request body for HMAC verification — the signature covers the raw bytes, not the parsed JSON. Add a custom body reader to preserve the raw body:

defmodule MyApp.RawBodyPlug do
  def init(opts), do: opts

  def call(conn, _opts) do
    {:ok, body, conn} = Plug.Conn.read_body(conn)
    conn
    |> Plug.Conn.assign(:raw_body, body)
    |> Plug.Parsers.call(Plug.Parsers.init(parsers: [:json], json_decoder: Jason, body_reader: {__MODULE__, :read_body, [body]}))
  end

  def read_body(_conn, body, _opts), do: {:ok, body, nil}
end

DexcyJet Webhook Event Schema

DexcyJet sends webhook events in a consistent envelope:

{
  "event_id": "evt_01j9...unique",
  "event": "email.bounced",
  "occurred_at": "2026-03-09T07:14:23.451Z",
  "campaign_id": "cmp_01j...",
  "subscriber_id": "sub_01j...",
  "email": "user@example.com",
  "data": {
    "bounce_type": "hard",
    "smtp_code": 550,
    "smtp_enhanced_code": "5.1.1",
    "provider": "gmail"
  }
}

All event types follow the same envelope. The event field tells you what happened:

Event Description
email.delivered Message accepted by receiving server
email.opened Open pixel fired
email.clicked Link clicked (redirect tracked)
email.bounced Delivery failed (hard or soft)
email.complained Spam complaint received via FBL
email.unsubscribed One-click unsubscribe processed

Retry Strategy

DexcyJet retries failed webhook deliveries (any non-2xx response, or connection timeout) on an exponential backoff schedule:

Attempt Delay
1st retry 1 minute
2nd retry 5 minutes
3rd retry 30 minutes
4th retry 2 hours
5th retry 8 hours

After 5 retries, the event is marked as failed. You can view and manually replay failed webhook events from the DexcyJet dashboard or via the API:

curl -X POST https://jet.dexcy.in/api/v1/webhook_events/evt_01j.../replay \
  -H "Authorization: Bearer $DEXCYJET_API_KEY"

Your webhook endpoint should be idempotent

Because DexcyJet retries on failure, your endpoint may receive the same event twice — once on original delivery, once on retry (if your 200 response was delayed past the timeout). Use the event_id field to deduplicate:

def process_event(%{"event_id" => event_id} = payload) do
  case MyApp.WebhookEvents.find_by_event_id(event_id) do
    nil ->
      MyApp.WebhookEvents.insert(%{event_id: event_id, processed_at: DateTime.utc_now()})
      handle_event(payload)
    _existing ->
      :already_processed
  end
end

When Polling Is Appropriate

Webhooks are better for real-time events, but polling is still useful for:

  • Bulk historical queries: “Show me all events for campaign X” — use the campaigns API, not webhooks
  • Diagnostic tooling: One-off checks of delivery status for a specific message
  • Development: Testing event processing logic locally without ngrok or a public URL

DexcyJet’s events API supports both real-time webhooks and historical polling queries. For production systems, use webhooks for real-time processing and the API for historical analysis and reporting.

See our features page for the full webhook configuration UI, or sign up to start receiving events.

Try DexcyJet: HMAC-SHA256 signed webhooks, configurable retry policy, and event replay — infrastructure-grade event delivery. Start free.

Stay sharp on email deliverability.

Get new posts on email infrastructure, compliance, and engineering delivered directly. No spam — we eat our own cooking.

Try DexcyJet free →

Related posts

More on topics from this article.