Why Webhooks Matter
Polling an API to check email status wastes bandwidth, adds latency, and scales poorly. Webhooks flip the model: instead of you asking "did it deliver?", your email provider tells you the moment something happens.
With SMTPfast, you can receive webhook events for deliveries, bounces, opens, clicks, and complaints.
Setting Up Your Webhook Endpoint
Your webhook endpoint is a POST route that accepts JSON payloads. Here is a basic Express.js example:
const express = require("express");
const app = express();
app.use(express.json());
app.post("/webhooks/email", (req, res) => {
const event = req.body;
console.log("Received event:", event.type, event.email_id);
// Always respond 200 quickly
res.sendStatus(200);
});
app.listen(3000);
The most important rule: respond with a 200 status immediately. Process the event after responding. If your endpoint takes too long, the provider may consider it failed and retry.
Processing Events Asynchronously
Do not do heavy processing inside the webhook handler. Acknowledge the event and queue the work.
const { Queue } = require("bullmq");
const emailEvents = new Queue("email-events");
app.post("/webhooks/email", async (req, res) => {
// Respond immediately
res.sendStatus(200);
// Queue the event for processing
await emailEvents.add("process-event", req.body);
});
Then process the queue in a worker:
const { Worker } = require("bullmq");
new Worker("email-events", async (job) => {
const event = job.data;
switch (event.type) {
case "delivered":
await markEmailDelivered(event.email_id);
break;
case "bounced":
await handleBounce(event.recipient, event.bounce_type);
break;
case "complained":
await suppressRecipient(event.recipient);
break;
case "opened":
await trackOpen(event.email_id);
break;
case "clicked":
await trackClick(event.email_id, event.link);
break;
}
});
Handling Each Event Type
Delivered
The email reached the recipient's mail server. This does not mean it landed in the inbox - it could be in spam - but it was accepted.
async function markEmailDelivered(emailId) {
await db.emails.update({
where: { id: emailId },
data: { status: "delivered", delivered_at: new Date() },
});
}
Bounced
The email could not be delivered. There are two types:
- Hard bounce - The address does not exist. Remove it from your list immediately.
- Soft bounce - Temporary failure (mailbox full, server down). Retry a few times, then suppress.
async function handleBounce(recipient, bounceType) {
if (bounceType === "hard") {
// Permanent failure - suppress this address
await db.suppressionList.create({
data: { email: recipient, reason: "hard_bounce" },
});
await db.users.update({
where: { email: recipient },
data: { email_verified: false },
});
} else {
// Soft bounce - increment counter, suppress after 3
const count = await db.softBounces.increment(recipient);
if (count >= 3) {
await db.suppressionList.create({
data: { email: recipient, reason: "soft_bounce" },
});
}
}
}
Complained
The recipient marked your email as spam. This is the most damaging event for your sender reputation. Immediately suppress the address and never send to it again.
async function suppressRecipient(recipient) {
await db.suppressionList.create({
data: { email: recipient, reason: "complaint" },
});
// Consider also unsubscribing them from all communications
await db.users.update({
where: { email: recipient },
data: { email_opt_out: true },
});
}
Verifying Webhook Signatures
Never trust webhook payloads blindly. Verify that the request actually came from your email provider.
SMTPfast signs webhook payloads with a shared secret. Verify it like this:
const crypto = require("crypto");
function verifyWebhookSignature(payload, signature, secret) {
const expected = crypto
.createHmac("sha256", secret)
.update(JSON.stringify(payload))
.digest("hex");
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected)
);
}
app.post("/webhooks/email", (req, res) => {
const signature = req.headers["x-smtpfast-signature"];
if (!verifyWebhookSignature(req.body, signature, process.env.WEBHOOK_SECRET)) {
return res.sendStatus(401);
}
res.sendStatus(200);
// Process event...
});
Use crypto.timingSafeEqual instead of === to prevent timing attacks.
Handling Retries and Deduplication
Webhook deliveries can fail and get retried. Your endpoint might receive the same event more than once. Make your processing idempotent.
async function processEvent(event) {
// Check if we already processed this event
const existing = await db.webhookEvents.findUnique({
where: { event_id: event.id },
});
if (existing) {
console.log("Duplicate event, skipping:", event.id);
return;
}
// Store the event ID before processing
await db.webhookEvents.create({
data: { event_id: event.id, type: event.type, processed_at: new Date() },
});
// Now process it
switch (event.type) {
// ... handle events
}
}
Monitoring and Alerting
Set up alerts for unusual webhook patterns:
// Track event counts per hour
async function checkWebhookHealth() {
const lastHour = new Date(Date.now() - 60 * 60 * 1000);
const bounces = await db.webhookEvents.count({
where: { type: "bounced", processed_at: { gte: lastHour } },
});
const total = await db.webhookEvents.count({
where: { processed_at: { gte: lastHour } },
});
const bounceRate = total > 0 ? bounces / total : 0;
if (bounceRate > 0.05) {
await alertTeam(`High bounce rate: ${(bounceRate * 100).toFixed(1)}%`);
}
}
Checklist
- Webhook endpoint responds 200 immediately
- Events processed asynchronously via a queue
- Hard bounces suppressed immediately
- Complaints result in permanent suppression
- Webhook signatures verified on every request
- Idempotent processing (handle duplicate events)
- Monitoring and alerting for unusual patterns
- Endpoint secured with HTTPS
Ready to get started?
Start sending transactional email today. Free to start, no credit card required.
Get Started for FreeRelated Posts
One approval instead of five DNS records: Domain Connect on SMTPfast
If your domain is on Cloudflare, SMTPfast can publish its sending records, and separately the inbound MX, through Domain Connect. You review each set on Cloudflare's screen, approve, and the records are written for you. Here is how it works, what gets written, and what to do when your DNS lives elsewhere.
Logs API and webhook deliveries: answer 'did it send?' without opening the dashboard
Two additions for the debugging side of email: the event log behind the Logs page is now an API, and every webhook has a delivery log with attempts, responses, automatic retries and a retry button.