New guide: how to warm up a dedicated IP without hurting your reputation. Read now →
Log in
MigoSMTP
Webhooks and eventsUpdated 14 Oct 2026

Email Webhooks: Receive Delivery Events and Verify Signatures Securely

Webhooks push delivery, bounce, complaint and engagement events to your application as they happen. This guide shows how to build a receiving endpoint, verify the signature on every request, answer quickly, handle retries and duplicate events safely, and test the whole flow before you rely on it.

KM
Written by Karan Malhotra
API and Developer Experience Lead
Reviewed by Jonas Lindqvist
  • Published16 Jun 2026
  • Read time5 min
  • Length908 words
Share this guide

Email webhooks send delivery, bounce, complaint and engagement events to your application as they happen, so you can suppress bad addresses, update records and alert your team without polling an API. This guide shows how to build a secure receiving endpoint, verify every signature, handle retries and duplicates, and test the flow.

3rules: verify, acknowledge, deduplicate
2xxstatus code that confirms receipt
1secret per endpoint, never shared

Check the exact names in your dashboard. Header names, event names and payload fields in this guide are illustrative. Use the names shown in your MigoSMTP webhook settings and event reference, and keep this guide for the pattern.

How webhooks work

When an event occurs, the platform sends an HTTPS POST request with a JSON body to the URL you registered. Your endpoint checks the request, stores the event and replies. If the reply is not a success, the platform tries again later.

  1. The event happens. A message is delivered, bounces, is reported as spam or is clicked.
  2. The platform sends a POST request. The body is JSON and the request carries a signature header.
  3. Your endpoint verifies the signature. Invalid requests are rejected with a 4xx status.
  4. Your endpoint stores the event and answers 200. It does this before any slow work.
  5. A worker processes the event. It suppresses an address, updates a record or raises an alert.

Common event types

EventMeaningWhat your system usually does
deliveredThe receiving server accepted the messageMark the message as delivered
bouncedThe message was rejected permanently or temporarilySuppress hard bounces and review the reason
deferredThe receiver asked the sender to retry laterWatch for repeated deferrals at one provider
complainedThe recipient reported the message as spamStop mailing that address at once
unsubscribedThe recipient opted outUpdate consent and preferences
opened or clickedEngagement was recordedUpdate engagement scores, with the limits of open tracking in mind

The exact list for your account is in the event reference. See the bounce code guide for what the reasons in bounce events mean.

An example payload

A bounce event could look like this. Field names are illustrative.

{
  "id": "evt_8f2c1a",
  "type": "bounced",
  "created_at": "2026-05-12T09:41:07Z",
  "data": {
    "message_id": "msg_41b7",
    "recipient": "user@example.com",
    "bounce_type": "hard",
    "status": "5.1.1",
    "reason": "mailbox does not exist"
  }
}

Verify the signature

The signature is an HMAC-SHA256 of the raw request body, calculated with the secret you saved when you registered the endpoint. Compute the same value, then compare the two with a constant-time function so timing differences leak nothing.

PHP example

<?php
$secret    = getenv('WEBHOOK_SECRET');
$raw       = file_get_contents('php://input');
$given     = $_SERVER['HTTP_X_SIGNATURE'] ?? '';
$expected  = hash_hmac('sha256', $raw, $secret);

if (!hash_equals($expected, $given)) {
    http_response_code(401);
    exit;
}

$event = json_decode($raw, true);
// store $event, then answer quickly
http_response_code(200);

Node.js example

import crypto from 'node:crypto';
import express from 'express';

const app = express();

app.post('/webhooks/mail', express.raw({ type: 'application/json' }), (req, res) => {
  const expected = crypto
    .createHmac('sha256', process.env.WEBHOOK_SECRET)
    .update(req.body)
    .digest('hex');
  const given = req.get('x-signature') || '';

  const ok = given.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(given), Buffer.from(expected));
  if (!ok) return res.sendStatus(401);

  const event = JSON.parse(req.body.toString());
  // enqueue event, then answer
  res.sendStatus(200);
});

Three common mistakes. Parsing and re-encoding the JSON before hashing, comparing signatures with a plain equals check, and logging the secret. Hash the raw bytes, use hash_equals or timingSafeEqual and keep the secret in an environment variable.

Handle retries and duplicates

Delivery is at least once, not exactly once. The same event can arrive twice and events can arrive out of order. Design for both.

ProblemWhy it happensHow to handle it
Duplicate eventA retry after a timeout or a lost responseStore the event id with a unique constraint and ignore repeats
Out-of-order eventsParallel delivery and retriesCompare timestamps or states before overwriting a record
Slow endpointWork done inside the requestStore, answer 200 and process in a queue
Long outageEndpoint unreachable beyond the retry windowBackfill from the events API or logs after recovery
Burst of eventsA large campaign finishingScale workers and keep the endpoint stateless
CREATE TABLE webhook_events (
  event_id    VARCHAR(64) PRIMARY KEY,
  type        VARCHAR(40) NOT NULL,
  payload     JSON NOT NULL,
  received_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  processed   TINYINT(1) DEFAULT 0
);

Security checklist

  • HTTPS only: the endpoint must use a valid certificate.
  • Verify every request: reject anything without a valid signature before reading the body.
  • Check the timestamp: if one is signed, refuse requests older than a few minutes.
  • Limit exposure: use a long random path, restrict methods to POST and apply rate limits.
  • Keep secrets out of code: store them in environment variables or a secret manager and rotate them on a schedule or after any suspected leak.
  • Log safely: record event ids and outcomes, not secrets or full personal data.

Test before you rely on it

TestExpected result
Send a valid test eventYour endpoint answers 200 and stores one row
Send the same event againYour endpoint answers 200 and stores no second row
Change one character in the bodyYour endpoint answers 401
Stop the worker and send eventsEvents are stored and processed after the worker starts
Return a 500 on purposeThe platform retries and the event arrives again

Need help with your endpoint? Send us your endpoint URL pattern and a sample failed delivery and we will help you find the cause.

Contact support

Further reading and sources

Frequently asked questions

What is an email webhook?

An email webhook is an HTTP request that the email platform sends to a URL you control when something happens to a message, for example when it is delivered, bounces, is reported as spam or is clicked. It lets your application react straight away instead of asking the API again and again.

Why should I verify the webhook signature?

Your endpoint is a public URL, so anyone who finds it can post data to it. A signature proves the request came from the platform and that the body was not changed on the way. Without verification an attacker could mark real addresses as bounced or trigger actions in your system.

How does HMAC signature verification work?

The sender and you share a secret. The sender computes an HMAC of the request body with that secret and sends the result in a header. You compute the same value from the exact bytes you received and compare. If the values match, the request is genuine.

Why must I use the raw request body?

The signature is calculated over the exact bytes that were sent. If your framework parses the JSON and you serialise it again, spacing and key order can change and the signature will no longer match. Read the raw body first, verify it and only then parse it.

What should my endpoint return?

Return a 2xx status code as soon as you have stored the event. Return a 4xx code for requests you reject, such as an invalid signature. A 5xx code, a timeout or no response is treated as a failure and the event is normally retried.

What happens when my endpoint is down?

Most platforms retry failed deliveries on a schedule with growing delays and give up after a set number of attempts. Check the retry policy and the event retention period in the webhook documentation, and use the events API or logs to backfill anything you missed during a long outage.

Can I receive the same event twice?

Yes. Retries, network problems and timeouts can lead to duplicates, and ordering is not guaranteed. Treat every handler as idempotent: store the event id, skip ids you have seen and make your actions safe to repeat.

Why should I process events in a queue?

Webhook senders expect a quick answer. If you do slow work such as database updates, external calls or sending messages inside the request, you risk timeouts and retries. Store the event, answer with 200 and let a worker do the rest.

How do I protect against replay attacks?

If the signature includes a timestamp, reject requests that are older than a few minutes and keep a record of event ids. A captured request then cannot be replayed later. Check the webhook documentation for how the timestamp is delivered and signed.

How can I test a webhook on my laptop?

Use a tunnelling tool to expose your local server on a temporary HTTPS URL, register that URL for a test webhook and send a test event. Remove the tunnel and the test registration when you finish, and never reuse a production secret for testing.

Topics#webhooks#events#hmac#signature#api#bounces#developers
Was this guide helpful?Your answer helps us improve our documentation.

Start sending in minutes.

Create an account, add your domain and send your first email.

Start free Talk to sales