Receive delivery events with webhooks
Add a webhook URL that receives delivery, bounce and complaint events, and check the signature on each request.
In the dashboard
A webhook is an HTTPS address in your application that we send a request to each time something happens to a message the relay sends: we queue it, the receiving server takes it or refuses it, or the recipient complains about it. Use webhooks to react to bounces and complaints as they happen.
Each request carries one event as JSON, signed with a secret that only you and we hold, so your application can check that the request came from us.
Before you begin
Section titled Before you begin- An SMTP Relay service with the status
active. While it is not, Add webhook… is greyed out. - An endpoint in your application that accepts
POSTrequests at anhttps://URL reachable from the internet, with a valid certificate from a public certificate authority. We do not follow redirects, so use the final URL. - Fewer than 10 webhooks on the relay. A webhook we have turned off still counts.
Add a webhook
Section titled Add a webhook- In the dashboard, go to Email, open the SMTP Relay service, then the Webhooks tab.
- Select Add webhook….
- In Endpoint URL, enter the address of your endpoint, such as
https://app.example.com/hooks/mail. - Under Events, clear the events you do not want. Every event is selected at first, and Events describes each one.
- Select Add webhook.
- Copy the Signing secret from the dialog and store it where the application keeps its secrets. We show it only this once.
- Select I have saved them.
Result
Section titled ResultThe webhook is listed on the Webhooks tab with its URL, the events it receives, its Status and when it was Created. Its status is Healthy until a request to it fails.
The webhook receives the events we record from then on, and none from before. Requests go out about once a minute, so expect each one a minute or so after its event.
Events
Section titled Events| Event | What happened | type values |
|---|---|---|
accepted |
We queued the message for delivery. | queue.authenticated-message-queued, queue.message-queued |
delivered |
The receiving server took the message. | delivery.delivered, delivery.dsn-success |
deferred |
The receiving server refused the message for now, or a sending limit held it back. We try again later. | delivery.dsn-temp-fail, delivery.rate-limit-exceeded, queue.rate-limit-exceeded, queue.quota-exceeded |
bounced |
The message was not delivered and we will not try again. We add the recipient to the suppression list. | delivery.failed, delivery.dsn-perm-fail, delivery.double-bounce |
complaint |
A report about the message reached us: the recipient marked it as spam, or a mailbox provider reported it as fraud or as failing authentication. We add the recipient to the suppression list. | incoming-report.abuse-report, incoming-report.fraud-report, incoming-report.auth-failure-report |
The dialog also offers suppressed, described as "Dropped: the address is on the suppression list", but we never send a request for it. Over SMTP the relay refuses a suppressed address with 550 5.1.1, and the send API leaves it out and lists it in the suppressed field of its answer.
A message the relay refuses when your application submits it, such as one from a domain that is not verified, produces no request either. Your application sees the refusal as the SMTP reply or the API error.
The request
Section titled The requestEach request is a POST with Content-Type: application/json and one event in the body. It carries two headers of its own:
X-Mail-Signature- The HMAC-SHA256 of the raw body, made with the webhook's signing secret and written as lower-case hex. Verify the signature shows how to check it.
X-Mail-Event- The event, the same as
eventin the body, such asdelivered.
The body is compact JSON with its keys in alphabetical order, exactly as we signed it:
{"event":"delivered","from":"receipts@example.com","id":90412,"message_id":"<175890432171.2481.9311874401294517206@example.com>","occurred_at":"2026-09-16T10:52:08","queue_id":"7d2c91a04e","response":"250 2.0.0 OK","to":"alex@example.com","type":"delivery.delivered"}
id- The event's id, a number. It stays the same on every attempt to send the event, so use it to spot a repeat.
event- The event:
accepted,delivered,deferred,bouncedorcomplaint. type- The detailed event the mail server recorded, one of the
typevalues in Events. queue_id- Our id for the message in the delivery queue, the same on every event about that message.
message_id- The message's
Message-IDheader. from- The sender address, in lower case.
to- The recipient address, in lower case. An event about the whole message, such as
accepted, names only its first recipient. response- The receiving server's reply or the reason for the event, up to 2,000 characters.
occurred_at- When the event happened, in UTC, with no time zone suffix.
Any field except id, event, type and occurred_at is null when the event does not carry it, so do not assume that every event has every field. The Payload card on the Webhooks tab sums up the method, the signature and the fields.
Verify the signature
Section titled Verify the signatureCheck the signature before you act on a request:
- Read the raw body as bytes, before a JSON parser touches it. A parser that reads the JSON and writes it out again can change the bytes, and then the signature no longer matches.
- Compute the HMAC-SHA256 of those bytes with the signing secret as the key. Use the secret as text, exactly as we showed it: do not decode it.
- Write the result as lower-case hex and compare it with
X-Mail-Signature, using a comparison that takes the same time whatever the input. - When they differ, answer
401and ignore the body.
In Python with Flask:
import hashlib
import hmac
import os
from flask import Flask, abort, request
app = Flask(__name__)
SECRET = os.environ["MAIL_WEBHOOK_SECRET"].encode()
@app.post("/hooks/mail")
def mail_event():
body = request.get_data()
expected = hmac.new(SECRET, body, hashlib.sha256).hexdigest().encode()
given = request.headers.get("X-Mail-Signature", "").encode()
if not hmac.compare_digest(expected, given):
abort(401)
handle(request.get_json())
return "", 204
In Node.js with Express:
import crypto from "node:crypto";
import express from "express";
const app = express();
const secret = process.env.MAIL_WEBHOOK_SECRET;
app.post("/hooks/mail", express.raw({ type: "application/json" }), (req, res) => {
const expected = Buffer.from(crypto.createHmac("sha256", secret).update(req.body).digest("hex"));
const given = Buffer.from(req.get("X-Mail-Signature") || "");
if (given.length !== expected.length || !crypto.timingSafeEqual(given, expected)) {
return res.sendStatus(401);
}
handle(JSON.parse(req.body.toString("utf8")));
res.sendStatus(204);
});
app.listen(3000);
In both, handle stands for your own code. Answer the request says how long it may take.
The signature covers the body alone. The body holds occurred_at, so you can refuse an old event, and id, so you can refuse one you have already handled.
Answer the request
Section titled Answer the requestAnswer with any 2xx status within 10 seconds. We count everything else as a failure: another status, a redirect, a timeout, a refused connection or a certificate we cannot verify. When your handling takes longer, store the event, answer, and process the event afterwards.
After a failed attempt we send the same event again, up to six attempts in all. We wait about 2 minutes before the second attempt, then about 4, 8, 16 and 32 minutes before each of the next four. When the sixth attempt fails, we stop sending that event to the webhook. It is still on the Events tab.
Because of retries, the same event can arrive more than once, and events can arrive in a different order from the one they happened in. Use id to ignore a repeat and occurred_at to put events in order.
When we turn a webhook off
Section titled When we turn a webhook offEvery failed attempt, for any event, adds one to the webhook's count of failures in a row, and a request that succeeds sets the count back to zero. At 50 failures in a row we turn the webhook off: we drop the events waiting for it and send it nothing more. A relay that sends a lot can reach 50 within minutes of an outage on your side, because each waiting event counts on its own.
The Status column shows where each webhook stands:
- Healthy
- The last request succeeded, or we have not sent one yet.
3 failing- The last three attempts failed.
- Disabled
- We turned the webhook off after 50 failures in a row.
Under the status, a line such as Last HTTP 503 · 12 minutes ago gives the status code your endpoint last answered with and the time since the last request that succeeded.
You cannot turn a webhook back on. Fix the endpoint, then delete the webhook and add it again. The new webhook has a new signing secret, and we do not send the events from the time the old one was off.
Replace the signing secret
Section titled Replace the signing secretWe cannot show a signing secret again or change it. To move to a new secret without missing events:
- Add a second webhook with the same URL and events, and save its signing secret.
- Make your endpoint accept a signature made with either secret.
- Delete the old webhook.
- Remove the old secret from your endpoint.
While both webhooks exist, each event arrives twice with the same id, once from each webhook.
Delete a webhook
Section titled Delete a webhookWarning
Deleting a webhook cannot be undone. We stop sending to it at once and drop the events waiting for it.
- On the Webhooks tab, select Delete… in the webhook's row.
- Type
deleteto continue, then select Delete webhook.
The webhook leaves the list, and a toast confirms Webhook deleted.
Troubleshooting
Section titled Troubleshooting- Add webhook… is greyed out
- The relay's status is not
active. Enter a URL starting with https://.- The address in Endpoint URL does not start with
https://in lower case. We send events only over HTTPS. Choose at least one event.- Every event is cleared. Select at least one under Events.
Limit of 10 webhooks per service- The relay already has 10 webhooks. Webhooks we have turned off count, so delete one you no longer use.
- Disabled
- Fifty attempts in a row failed, so we turned the webhook off. Fix the endpoint, then delete the webhook and add it again.
- The signature does not match
- Compute it over the raw body before you parse the JSON, use the secret as text exactly as we showed it, and compare lower-case hex. Each webhook has its own secret, so use the one we showed when you added this webhook.
- No requests arrive
- Check that the webhook's events include the one you expect, that the endpoint is reachable from the internet over HTTPS with a valid certificate, and that it does not answer with a redirect. Requests go out about once a minute, and a new webhook receives only events we record after you add it.
- You lost the signing secret
- We cannot show it again. Follow Replace the signing secret.
Related
Section titled Related- Look up message events
- Manage the suppression list
- Send email over HTTPS
- SMTP Relay webhooks API reference
With the API
Section titled With the APIAdd a webhook:
curl -X POST https://api.coritan.com/api/v1/client/smtp-relay/4812/webhooks \
-H "Authorization: Bearer $CORITAN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"url": "https://app.example.com/hooks/mail", "events": ["bounced", "complaint"]}'
url- 8–1,024 characters, starting with
https://. events- Optional. The events to send, from
accepted,delivered,deferred,bounced,complaintandsuppressed. We drop any other name. An empty list, or one with no name we know, means all six.
The answer is 201 with the signing secret in secret, shown this once:
{
"id": 214,
"url": "https://app.example.com/hooks/mail",
"events": ["bounced", "complaint"],
"enabled": true,
"failure_count": 0,
"last_status": null,
"last_delivered_at": null,
"created_at": "2026-09-16T10:41:27.318204+00:00",
"secret": "3kQ9vR2xT7mW1pL5nH8cJ4bF6gD0sZyAeUoIiKqNtMw"
}
enabled is false once we have turned the webhook off. failure_count is its count of failures in a row, last_status is the status code your endpoint last answered with, and last_delivered_at is when a request to it last succeeded, in UTC.
The other operations:
| Operation | Answer |
|---|---|
GET /client/smtp-relay/{service_id}/webhooks |
{"items": [...]}: every webhook, including ones we have turned off, as above without secret |
DELETE /client/smtp-relay/{service_id}/webhooks/{webhook_id} |
{"ok": true}. We stop sending to the webhook at once. |
No operation turns a webhook back on or changes its URL, events or secret. A URL that does not start with https:// answers 400 Webhook URLs must use https://, a new webhook on a relay that has 10 answers 400 Limit of 10 webhooks per service, and a webhook id that is not on the relay answers 404 Not found.
On a Mail Hosting service
Section titled On a Mail Hosting serviceMail Hosting has no Webhooks tab, but the same operations work under /client/mail/{service_id}/webhooks. A webhook there receives the events we record for mail the service's mailboxes send. Some bounces reach the sending mailbox only as an Undelivered Mail Returned to Sender message, so a webhook there does not see every bounce.
API operations on this page
| Method | Path | What it does |
|---|---|---|
GET | /api/v1/client/smtp-relay/{service_id}/webhooks | List webhooks |
POST | /api/v1/client/smtp-relay/{service_id}/webhooks | Create webhook |
DELETE | /api/v1/client/smtp-relay/{service_id}/webhooks/{webhook_id} | Delete webhook |
GET | /api/v1/client/mail/{service_id}/webhooks | List webhooks |
POST | /api/v1/client/mail/{service_id}/webhooks | Create webhook |
DELETE | /api/v1/client/mail/{service_id}/webhooks/{webhook_id} | Delete webhook |