Skip to content
Coritan Docs

Receive delivery events with webhooks

Add a webhook URL that receives delivery, bounce and complaint events, and check the signature on each request.

View as Markdown

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.

  • An SMTP Relay service with the status active. While it is not, Add webhook… is greyed out.
  • An endpoint in your application that accepts POST requests at an https:// 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.
  1. In the dashboard, go to Email, open the SMTP Relay service, then the Webhooks tab.
  2. Select Add webhook….
  3. In Endpoint URL, enter the address of your endpoint, such as https://app.example.com/hooks/mail.
  4. Under Events, clear the events you do not want. Every event is selected at first, and Events describes each one.
  5. Select Add webhook.
  6. Copy the Signing secret from the dialog and store it where the application keeps its secrets. We show it only this once.
  7. Select I have saved them.

The 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.

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.

Each 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 event in the body, such as delivered.

The body is compact JSON with its keys in alphabetical order, exactly as we signed it:

JSON
{"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, bounced or complaint.
type
The detailed event the mail server recorded, one of the type values 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-ID header.
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.

Check the signature before you act on a request:

  1. 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.
  2. 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.
  3. 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.
  4. When they differ, answer 401 and ignore the body.

In Python with Flask:

Python
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:

JavaScript
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 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.

Every 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.

We cannot show a signing secret again or change it. To move to a new secret without missing events:

  1. Add a second webhook with the same URL and events, and save its signing secret.
  2. Make your endpoint accept a signature made with either secret.
  3. Delete the old webhook.
  4. Remove the old secret from your endpoint.

While both webhooks exist, each event arrives twice with the same id, once from each webhook.

Warning

Deleting a webhook cannot be undone. We stop sending to it at once and drop the events waiting for it.

  1. On the Webhooks tab, select Delete… in the webhook's row.
  2. Type delete to continue, then select Delete webhook.

The webhook leaves the list, and a toast confirms Webhook deleted.

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.

Add a webhook:

Shell
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, complaint and suppressed. 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:

JSON
{
  "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.

Mail 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