# Receive delivery events with webhooks

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

Source: https://www.coritan.com/docs/mail/smtp-relay/webhooks/

In the dashboard:

- /dashboard/mail/…/webhooks: https://www.coritan.com/dashboard/mail

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

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

## Add a webhook

1. In the dashboard, go to [**Email**](https://www.coritan.com/dashboard/mail), 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](#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**.

## Result

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.

## 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](/docs/mail/smtp-relay/suppressions/). | `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

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](#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](#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.

## Verify the signature

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](#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

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](/docs/mail/smtp-relay/events/).

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

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.

## Replace the signing secret

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.

## Delete a 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.`

## 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](#replace-the-signing-secret).

## Related

- [Look up message events](/docs/mail/smtp-relay/events/)
- [Manage the suppression list](/docs/mail/smtp-relay/suppressions/)
- [Send email over HTTPS](/docs/mail/smtp-relay/send-with-the-api/)
- [SMTP Relay webhooks API reference](/docs/api/reference/client/mail/smtp-relay-webhooks/)

## With the API

Add a webhook:

```bash
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`.

### On a Mail Hosting service

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

- `GET /api/v1/client/smtp-relay/{service_id}/webhooks`: List webhooks (https://www.coritan.com/docs/api/reference/client/mail/smtp-relay-webhooks/#op-get-api-v1-client-smtp-relay-service-id-webhooks)
- `POST /api/v1/client/smtp-relay/{service_id}/webhooks`: Create webhook (https://www.coritan.com/docs/api/reference/client/mail/smtp-relay-webhooks/#op-post-api-v1-client-smtp-relay-service-id-webhooks)
- `DELETE /api/v1/client/smtp-relay/{service_id}/webhooks/{webhook_id}`: Delete webhook (https://www.coritan.com/docs/api/reference/client/mail/smtp-relay-webhooks/#op-delete-api-v1-client-smtp-relay-service-id-webhooks-webhook-id)
- `GET /api/v1/client/mail/{service_id}/webhooks`: List webhooks (https://www.coritan.com/docs/api/reference/client/mail/mail-webhooks/#op-get-api-v1-client-mail-service-id-webhooks)
- `POST /api/v1/client/mail/{service_id}/webhooks`: Create webhook (https://www.coritan.com/docs/api/reference/client/mail/mail-webhooks/#op-post-api-v1-client-mail-service-id-webhooks)
- `DELETE /api/v1/client/mail/{service_id}/webhooks/{webhook_id}`: Delete webhook (https://www.coritan.com/docs/api/reference/client/mail/mail-webhooks/#op-delete-api-v1-client-mail-service-id-webhooks-webhook-id)
