Email webhooks: real-time event tracking for developers
Set up email webhooks to track deliveries, bounces, opens, and complaints in real time. Code examples for event handling, retries, and idempotency.
When your application sends an email, the story does not end at the API response. The email passes through multiple systems before it reaches the recipient's inbox, and each step produces events: delivery confirmations, bounces, opens, clicks, and complaints. Email webhooks give you visibility into every one of those events.
Webhook: an HTTP callback that your email provider sends to your server when an event occurs. Instead of polling for updates, your application receives them in real time.
Nuntly delivers webhook events with automatic retries (up to 10 attempts with exponential backoff) and 30-day event retention. If your endpoint is down, no event is lost.
Why email webhooks instead of polling
You could poll the email API to check the status of each message, but polling does not scale well for event-driven workflows.
| Polling | Webhooks | |
|---|---|---|
| Latency | Poll interval (seconds to minutes) | Near-zero |
| Wasted requests | Most return no new data | Only fires on events |
| Scaling cost | Grows with email volume | One request per event |
| Rate limits | Often exceeds API limits | Not applicable |
| Complexity | Backoff, state tracking, pagination | Single HTTP endpoint |
Webhooks invert the model. Instead of asking "did anything happen?", your email provider pushes events to your endpoint as they occur. You process only the events that matter.
Event types
When you configure a webhook with Nuntly, you choose which event types to subscribe to. Here are the events available:
Delivery events
email.sent: Nuntly has accepted the email and is processing it for delivery.email.delivered: The receiving mail server accepted the email. This is the strongest delivery confirmation.email.bounced: The email was rejected. Includes a bounce type (permanent or transient) and a reason code.email.deliveryDelayed: A temporary delay occurred. The email will be retried automatically.
Engagement events
email.opened: A recipient opened the email. Tracked via a tracking pixel embedded in the HTML body. Privacy features like Apple Mail Privacy Protection can trigger opens automatically, so treat open rates as directional, not exact.email.clicked: A recipient clicked a tracked link in the email. The event includes the destination URL.
Complaint events
email.complained: The recipient marked the email as spam. This is a critical signal. Even a small number of complaints can damage your sender reputation and affect deliverability for all your emails.
Setting up a webhook
You can create a webhook endpoint through the Nuntly API or dashboard. Here is how to create one with the TypeScript SDK:
import { Nuntly } from '@nuntly/sdk';const nuntly = new Nuntly();const { data } = await nuntly.webhooks.create({url: 'https://yourapp.com/webhooks/email-events',events: ['email.delivered', 'email.bounced', 'email.opened', 'email.clicked', 'email.complained'],description: 'Production email event handler',});console.log('Webhook created:', data.id);console.log('Signing secret:', data.signingSecret);
Store the signing secret securely. You will use it to verify that incoming requests are genuinely from Nuntly.
Building a webhook handler
A webhook handler does three things: verify the signature, process the event, and respond with a 2xx status.
Every webhook request from Nuntly includes an HMAC-SHA256 signature in the webhook-signature header. Verify it before doing anything else. See the webhook integration guide for the full signature verification implementation.
import express from 'express';const app = express();const WEBHOOK_SECRET = process.env.WEBHOOK_SIGNING_SECRET;app.post('/webhooks/email-events', express.text({ type: 'application/json' }), async (req, res) => {const payload = req.body;const signatureHeader = req.headers['webhook-signature'] as string;// 1. Verify the signatureif (!verifyWebhookSignature(payload, signatureHeader, WEBHOOK_SECRET)) {return res.status(401).send('Invalid signature');}const event = JSON.parse(payload);// 2. Process the event// If processing is slow (external API calls, heavy DB writes),// push to a queue instead (Redis, SQS, BullMQ) and process async.await processEmailEvent(event);// 3. Respond so Nuntly knows the event was processedres.status(200).send('OK');});
The 200 response must be fast. If your endpoint takes more than a few seconds, Nuntly will consider the delivery failed and retry.
Handling each event type
Your queue worker picks up events and routes them by type. Here is a practical example that combines database updates, CRM syncing, and team notifications:
async function processEmailEvent(event: { type: string; emailId: string; data: Record<string, unknown> }) {switch (event.type) {case 'email.delivered':await db.emails.update(event.emailId, {status: 'delivered',deliveredAt: new Date(),});// Sync delivery status to your CRMawait crm.updateEmailActivity(event.emailId, 'delivered');break;case 'email.bounced':await db.emails.update(event.emailId, {status: 'bounced',bounceType: event.data.bounceType,bounceReason: event.data.reason,});if (event.data.bounceType === 'permanent') {// Suppress hard bounces to protect sender reputationawait db.suppressionList.add(event.data.recipient);// Alert the team so they can clean their contact listawait slack.send('#email-ops', `Hard bounce: ${event.data.recipient} (${event.data.reason})`);}break;case 'email.complained':await db.emails.update(event.emailId, { status: 'complained' });await db.suppressionList.add(event.data.recipient);// Complaints are critical: alert immediatelyawait slack.send('#email-ops', `Spam complaint from ${event.data.recipient} on email ${event.emailId}`);break;case 'email.opened':await db.emails.incrementOpenCount(event.emailId);break;case 'email.clicked':await db.emailClicks.record({emailId: event.emailId,url: event.data.url,clickedAt: new Date(),});// Notify sales when a prospect clicks a pricing linkif (event.data.url.includes('/pricing')) {await crm.createTask(event.emailId, 'Prospect clicked pricing link');}break;}}
The key patterns:
- Suppress hard bounces and complaints immediately to protect your sender reputation.
- Alert your team on Slack (or PagerDuty, email, etc.) for complaints and hard bounces so they can investigate.
- Sync to your CRM so sales has real-time visibility into email engagement.
- Trigger automations on specific actions like a prospect clicking a pricing link.
Handling retries and idempotency
Nuntly retries failed deliveries up to 10 times with exponential backoff (starting at 30 seconds, doubling each time). If your endpoint returns a non-2xx status or times out, the event will be retried.
Idempotent: a property of an operation where executing it multiple times produces the same result as executing it once. In the context of webhooks, your handler must be idempotent because retries can deliver the same event more than once. The HTTP specification defines idempotency in RFC 9110, section 9.2.2.
The simplest approach: use a unique constraint on the event ID in your database and skip duplicates.
async function processEmailEvent(event: { id: string; type: string; emailId: string; data: Record<string, unknown> }) {// INSERT ... ON CONFLICT (id) DO NOTHINGconst inserted = await db.processedEvents.insertIfNotExists({id: event.id,processedAt: new Date(),});if (!inserted) {return; // Duplicate, already processed}// Handle the event (see examples above)switch (event.type) {// ...}}
The database handles concurrency for you. If two retries arrive at the same time, only one insert succeeds.
Monitoring and event replay
After setting up your webhook, monitor its health:
- Track response times: If your endpoint consistently takes more than a few seconds to respond, you risk triggering retries.
- Monitor failure rates: A spike in failed deliveries might indicate a bug in your handler or an infrastructure issue.
- Check for gaps: Compare the number of emails sent against the number of events received. Significant gaps suggest a problem.
Nuntly retains webhook delivery logs for 30 days. Every delivery attempt is recorded with timestamps, HTTP status codes, and response bodies. If your endpoint was down during an incident, you can replay missed events from the dashboard or API once your endpoint is back online. No event is permanently lost.
"Migrated 50k daily emails from Mailgun in one deployment." - Bouziane Belhadi, Head of Engineering, 24S
24S reconfigured their webhook endpoints during the migration. Event replay meant zero data loss during the switchover.
Start tracking email events
Webhooks turn email sending from a fire-and-forget operation into a fully observable system. By subscribing to delivery, bounce, engagement, and complaint events, you can keep your application data accurate, protect your sender reputation, and react to problems in real time.
The foundation: verify signatures, process events, respond quickly, handle duplicates. The webhook integration guide covers the full implementation with Next.js and Express examples.
Ship emails, not infrastructure
Free plan available. No credit card required.
Start sending free