Skip to content

Webhooks

The relay can push message delivery and account safety changes to an HTTPS endpoint you control. Delivery webhooks are the push counterpart to polling /member/deliverability; member-health webhooks tell you when account, domain, policy, or submission gates change your ability to send.

Each event you subscribe to lands as a JSON POST signed with HMAC-SHA256 over a per-endpoint secret. You verify the signature, parse the body, and react. The relay retries failed deliveries and marks them dead after the retry limit. Your receiver must handle duplicate deliveries.

Message lifecycle:

  • message.delivered: the recipient MX accepted the message (2xx response after DATA).
  • message.bounced: the relay marked the message bounced. Hard bounces (5xx) and exhausted-retry transients (4xx that hit the retry cap) both fire this event.
  • message.complained: the relay received an ARF feedback report attributing a complaint to your domain. See Feedback loop monitoring for the providers and parsing model.

Account and domain lifecycle:

  • domain.verified: a sending domain finished verification.

  • account.suspended: the stored account state changed to suspended.

  • account.reactivated: a suspended account returned to active.

  • domain.paused: reputation protection paused a sending domain.

  • domain.unpaused: the domain pause was cleared. Another account or policy gate may still block sending.

Effective safety policy:

  • policy.applied: Osprey applied a policy-affecting label such as auto_suspended, domain_spray_suspended, or burst_warming.
  • policy.cleared: one of those policy labels was cleared.
  • submission.rejected: a permanent account, domain, DKIM, or authorization gate rejected a submission.
  • submission.throttled: a temporary safety-service, authorization-service, or rate-limit gate deferred a submission.

Submission events are deduplicated by member, domain, reason code, and UTC hour. A retrying SMTP client therefore produces one actionable notification per reason window, not one notification per attempt. These payloads never contain message content or recipient addresses.

Every payload is JSON with this envelope:

{
"id": "wh_01HX…",
"type": "message.bounced",
"createdAt": "2026-05-19T14:22:31Z",
"memberDid": "did:plc:yourdid",
"memberDomain": "news.example.com",
"data": { "…": "event-specific" }
}
  • id is the delivery id. The same id rides on the X-Atmos-Delivery header and is stable across retries; use it as your idempotency key.
  • type is the event type from the list above.
  • createdAt is RFC 3339 / ISO 8601 in UTC.
  • memberDid and memberDomain identify your account and the sending domain the event belongs to.
  • data carries event-specific fields described below.
{
"id": "wh_01HX…",
"type": "message.delivered",
"createdAt": "2026-05-19T14:22:31Z",
"memberDid": "did:plc:yourdid",
"memberDomain": "news.example.com",
"data": {
"messageId": "<a1b2c3@news.example.com>",
"recipient": "alice@example.com",
"smtpCode": 250
}
}
{
"id": "wh_01HX…",
"type": "message.bounced",
"createdAt": "2026-05-19T14:22:31Z",
"memberDid": "did:plc:yourdid",
"memberDomain": "news.example.com",
"data": {
"messageId": "<a1b2c3@news.example.com>",
"recipient": "bob@example.com",
"smtpCode": 550,
"bounceType": "hard",
"diagnostic": "550 5.1.1 user unknown"
}
}

bounceType is hard for permanent 5xx, soft for 4xx that exhausted the retry budget. diagnostic is the verbatim SMTP response from the recipient MX, truncated if very long but otherwise unmodified.

{
"id": "wh_01HX…",
"type": "message.complained",
"createdAt": "2026-05-19T14:22:31Z",
"memberDid": "did:plc:yourdid",
"memberDomain": "news.example.com",
"data": {
"feedbackType": "abuse",
"senderDomain": "news.example.com",
"recipientDomain": "yahoo.com",
"provider": "yahoo",
"arrival": "2026-05-19T14:21:47Z"
}
}

The ARF report does not carry a Message-ID the relay can correlate against a messages row reliably, so data.messageId is intentionally absent. Pair the event with your own send log on recipientDomain + arrival if you need a join key.

Member-health payloads use stable data.reasonCode values for automation and carry human-readable context separately. For example:

{
"id": "wh_01HX…",
"type": "policy.applied",
"createdAt": "2026-08-12T20:22:31Z",
"memberDid": "did:plc:yourdid",
"memberDomain": "news.example.com",
"data": {
"reasonCode": "osprey_auto_suspended",
"sourceLabel": "auto_suspended",
"active": true,
"sendingBlocked": true,
"policyStateKnown": true,
"policyEnforced": true,
"effect": "sending_blocked",
"remediation": "Review your Comail status and contact support if the traffic was expected."
}
}

Branch on reasonCode, not reason, summary, or remediation; prose may improve over time. For policy transitions, sendingBlocked describes the effective composite result after account-class exceptions, relay account state, and the sending domain’s pause state when policyStateKnown is true. hourlyLimitMultiplier and skipWarming expose the other effective Osprey modifiers from the same authoritative state. policyEnforced says whether this relay is configured to enforce the observed Osprey policy. A relay that consumes policy events without an Osprey send enforcer reports policyEnforced: false and effect: "policy_observed_unenforced" instead of claiming the policy blocked mail. A clear event never claims restoration from incomplete state: it uses effect: "effective_policy_unknown", or reports that a blocking or warming modifier remains active. For example, an enterprise account can receive an active domain_spray_suspended transition with sendingBlocked: false and effect: "enterprise_exception_applied". Current submission reason codes include domain_paused, osprey_auto_suspended, osprey_domain_spray_suspended, osprey_unavailable, rate_limit, dkim_dns_mismatch, label_unavailable, and label_denied.

GET https://smtp.atmos.email/member/status?did=... reports the effective account-level gate used by SMTP under safety, including sending_allowed, reason_code, temporary, source, hourly_limit_multiplier, and skip_warming. Authenticate with the same Authorization: Bearer <api_key> header used for HTTP submission. A stored account block or pending-deletion cooldown has source: "relay"; Osprey policy and availability decisions have source: "osprey". Poll no faster than once every five seconds. The endpoint returns 429 with Retry-After when the per-member rate or concurrency limit is reached.

Every POST carries:

Header Meaning
Content-Type application/json
User-Agent comail-webhook/1
X-Atmos-Signature t=<unix>,v1=<hex> (see signing below)
X-Atmos-Event The event type, mirrors body.type
X-Atmos-Delivery The delivery id, mirrors body.id
X-Atmos-Attempt 1 on the first attempt, increments on retry

X-Atmos-Signature is t=<unix>,v1=<hex> where <hex> is HMAC-SHA256 over "<t>.<raw-body>" using your endpoint’s secret as the key. The scheme is Stripe-style and uses hmac.Equal (constant-time) in the relay’s own verifier; receivers should do the same.

Always verify before parsing. An attacker who guesses your endpoint URL but not the secret can still POST garbage; the signature is what makes a payload trustworthy.

A 5-minute tolerance window on the timestamp blocks replay of an intercepted payload outside the window. The exact tolerance is your call; the relay’s own verifier accepts whatever you pass.

package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
)
const secret = "" // set from env at startup
const tolerance = 5 * time.Minute
func verify(secret []byte, sigHeader string, body []byte, now time.Time) error {
var ts int64
var got string
for _, part := range strings.Split(sigHeader, ",") {
kv := strings.SplitN(strings.TrimSpace(part), "=", 2)
if len(kv) != 2 {
continue
}
switch kv[0] {
case "t":
parsed, err := strconv.ParseInt(kv[1], 10, 64)
if err != nil {
return fmt.Errorf("bad timestamp: %w", err)
}
ts = parsed
case "v1":
got = kv[1]
}
}
if ts == 0 || got == "" {
return errors.New("malformed signature")
}
delta := now.Sub(time.Unix(ts, 0))
if delta < -tolerance || delta > tolerance {
return errors.New("timestamp out of tolerance")
}
mac := hmac.New(sha256.New, secret)
fmt.Fprintf(mac, "%d.", ts)
mac.Write(body)
want := hex.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(got), []byte(want)) {
return errors.New("signature mismatch")
}
return nil
}
func handler(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "read", http.StatusBadRequest)
return
}
if err := verify([]byte(os.Getenv("COMAIL_WEBHOOK_SECRET")),
r.Header.Get("X-Atmos-Signature"), body, time.Now()); err != nil {
http.Error(w, err.Error(), http.StatusUnauthorized)
return
}
// parse + react…
w.WriteHeader(http.StatusOK)
}
import crypto from "node:crypto";
import express from "express";
const TOLERANCE_MS = 5 * 60 * 1000;
function verify(secret, sigHeader, body, now = Date.now()) {
const parts = Object.fromEntries(
sigHeader.split(",").map((p) => p.trim().split("=", 2)),
);
const ts = Number.parseInt(parts.t, 10);
const got = parts.v1;
if (!Number.isFinite(ts) || !got) throw new Error("malformed signature");
const delta = Math.abs(now - ts * 1000);
if (delta > TOLERANCE_MS) throw new Error("timestamp out of tolerance");
const mac = crypto.createHmac("sha256", secret);
mac.update(`${ts}.`);
mac.update(body);
const want = mac.digest("hex");
if (!crypto.timingSafeEqual(Buffer.from(got), Buffer.from(want))) {
throw new Error("signature mismatch");
}
}
const app = express();
app.post(
"/comail-webhook",
express.raw({ type: "application/json" }),
(req, res) => {
try {
verify(
process.env.COMAIL_WEBHOOK_SECRET,
req.header("X-Atmos-Signature"),
req.body,
);
} catch (err) {
return res.status(401).send(err.message);
}
const payload = JSON.parse(req.body.toString("utf8"));
// parse + react…
res.sendStatus(200);
},
);
app.listen(8080);

Read the body as raw bytes, not as parsed JSON. The signature is over the exact bytes the relay sent; any reserialization will change them.

import hmac
import hashlib
import os
import time
from flask import Flask, abort, request
TOLERANCE = 5 * 60
def verify(secret: bytes, sig_header: str, body: bytes, now: float | None = None) -> None:
if now is None:
now = time.time()
parts = dict(p.strip().split("=", 1) for p in sig_header.split(",") if "=" in p)
try:
ts = int(parts["t"])
got = parts["v1"]
except (KeyError, ValueError) as e:
raise ValueError("malformed signature") from e
if abs(now - ts) > TOLERANCE:
raise ValueError("timestamp out of tolerance")
mac = hmac.new(secret, digestmod=hashlib.sha256)
mac.update(f"{ts}.".encode())
mac.update(body)
if not hmac.compare_digest(got, mac.hexdigest()):
raise ValueError("signature mismatch")
app = Flask(__name__)
@app.post("/comail-webhook")
def comail_webhook():
try:
verify(
os.environ["COMAIL_WEBHOOK_SECRET"].encode(),
request.headers["X-Atmos-Signature"],
request.get_data(),
)
except (KeyError, ValueError) as exc:
abort(401, str(exc))
# parse + react…
return "", 200

A non-2xx response (or a transport failure) schedules a retry. The schedule is:

Attempt Delay before next try
1st failure 30 s
2nd 2 min
3rd 10 min
4th 1 hour
5th 6 hours
6th 24 hours
7th dead-lettered

After the 7th failure the delivery is marked dead. The row stays in the log for audit; the relay stops attempting. A disabled endpoint follows the same attempt counter, so a member who toggles an endpoint off can’t accidentally leave immortal pending deliveries polling forever.

A 2xx (any status in 200–299) is success. Anything else, including 3xx, is a retry. The relay reads at most 16 KiB of the response body. There’s no need to return anything beyond a status code.

Two mechanisms keep duplicate events tractable:

  • Source-side idempotency: the relay deduplicates internal re-emits (e.g., a Kafka rebalance) against a (endpoint, idempotency_key) unique index. The dispatcher can still send a delivery more than once, including when dispatchers run concurrently.
  • Receiver-side idempotency: each POST carries X-Atmos-Delivery (also at body.id). It’s stable across retries. Persist it on your side and short-circuit if you’ve already processed it. Retries after a partial outage will re-deliver the same id.

Manual replay is available from /account/webhooks for any delivery row, including dead-lettered ones. Replays use a fresh delivery id, so X-Atmos-Delivery changes. A receiver that deduplicates only by delivery id will treat a manual replay as a new delivery.

  • HTTPS only. http:// endpoints are rejected at create time. There is no override in production.
  • SSRF guard. The dispatcher refuses to dial loopback, RFC 1918 private ranges, link-local (169.254.0.0/16, including cloud metadata at 169.254.169.254), CGNAT (100.64.0.0/10), IPv6 ULA / link-local, multicast, and the unspecified addresses. Endpoint URLs whose DNS resolves into any of those ranges fail with a transport error and never connect.
  • Verify the signature. A leaked endpoint URL without the secret should produce 401s, not accepted payloads. hmac.Equal / crypto.timingSafeEqual / hmac.compare_digest are constant-time; use them, not ==.
  • Rotate the secret if you suspect leakage. The rotate button on /account/webhooks returns a new secret once; the old one stops verifying immediately.

Sign into /account/webhooks to create, edit, disable, or rotate endpoints. Each endpoint takes:

  • A URL (HTTPS only).
  • An event filter: * for everything, or a CSV of specific event types. API clients that omit or leave this field blank receive only the original delivery events; subscribing to all current and future event types requires an explicit *.
  • An optional description, useful when you operate several endpoints.

The page also shows recent deliveries per endpoint, with each row’s status, HTTP response code, attempt count, and a replay button.

For a personal alert sink, ntfy’s HTTP template mode can render fields from the webhook JSON. Use a private, unguessable topic and configure a URL shaped like:

https://ntfy.sh/<private-topic>?tpl=yes&t=Comail+{{.type}}&m={{.memberDomain}}:+{{.data.reasonCode}}&p=4&tags=email,warning

This is convenient, but ntfy does not verify Comail’s HMAC signature for you. For an operational or automated receiver, terminate the webhook in a small service that verifies X-Atmos-Signature, then republishes the verified event to ntfy.

The same surface is available as JSON at /api/v1/account/webhooks (CRUD + delivery list + rotate-secret + replay) for scripted management. The dashboard is a thin client on top.

Back to docs