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:
- Takes the raw request body (as bytes)
-
Computes
HMAC-SHA256(body, your_webhook_secret) -
Includes the result as a hex string in the
X-DexcyJet-Signatureheader
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 →