Concepts
Webhooks
Everything else in this API is a request you send us; webhooks are the requests we send you — signed, retried, logged, and replayable from the delivery log.
Register an endpoint
POST /webhooks (scope webhooks:write) takes a URL and the events to subscribe to. The URL is validated at registration and must be publicly resolvable — a private-range address, a loopback address or a name that resolves to one is refused here rather than failing silently at delivery time.
curl -X POST "https://api.mailneo.co/api/v2/webhooks" \
-H "x-api-key: $MAILNEO_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://example.com/webhooks/mailneo",
"events": ["*"],
"name": "Production consumer"
}'secret (whsec_...), and it is the only time it is ever returned — not on a read, not on an update. It is encrypted at rest and cannot be shown again. Store it before you do anything else; if you lose it, delete the endpoint and register a new one, which gets a new secret.- Subscribing to events that carry contact data (
contact.*,event.tracked,suppression.added,unsubscribe.created) additionally requirescontacts:readorsuppressions:readon the key — a webhook delivers that data to an address you choose, so it may not grant a reach the key does not already have. - Custom
headersare sent with every delivery — useful for your own routing tokens. Reserved names (x-mailneo-*,content-type, ...) cannot be set. - Endpoints per team are capped; the cap answers
409 resource_limit_reached— delete one and retry, upgrading changes nothing.
The full CRUD surface — GET /webhooks, PATCH /webhooks/{webhookId}, DELETE /webhooks/{webhookId}, plus the delivery log — is in the webhooks reference.
The event catalog
GET /webhooks/events lists every event this API can deliver, with the name to put in an endpoint's events array. Discover the list rather than hard-coding it: an event name outside the catalog is rejected at registration, and the catalog grows. ["*"] subscribes to everything live now and everything added later.
curl "https://api.mailneo.co/api/v2/webhooks/events" \
-H "x-api-key: $MAILNEO_API_KEY"Headers on every delivery
| Header | Meaning |
|---|---|
| X-Mailneo-Signature | t=<unix seconds>,v1=<hex>. The hex is HMAC-SHA256 of <timestamp>.<raw body> keyed with your secret. |
| X-Mailneo-Timestamp | The same unix-seconds value that is inside the signature. |
| X-Mailneo-Event | The catalog event name, so you can route without parsing the body. |
| X-Mailneo-Event-Id | Deduplicate on this. Stable across every retry and across a manual replay — seeing it twice means you already handled this event. Ordering is deliberately not guaranteed; delivery is at-least-once. |
| X-Mailneo-Delivery-Id | This attempt. A retry or replay of the same event gets a new one. |
| X-Mailneo-Webhook-Id | The endpoint being delivered to. |
| X-Mailneo-Api-Version | The dated API version the payload is shaped by. |
| X-Mailneo-Replay | true when the delivery was triggered by hand rather than by the original event. |
Verifying signatures
The timestamp is inside the signed material — <timestamp>.<raw body> — not merely a header alongside it, so a captured delivery is only replayable inside the tolerance window. The v1= prefix is what lets a future scheme roll out gradually instead of breaking every consumer at once. Verify against the raw request body, before any JSON parsing: re-serializing changes the bytes — key order, whitespace, number formatting — and the signature will not match.
import crypto from "node:crypto";
// rawBody must be the UNPARSED bytes of the request.
function verifyMailneoSignature(rawBody, signatureHeader, secret) {
const parts = signatureHeader.split(",").map((p) => p.trim());
const tPart = parts.find((p) => p.startsWith("t="));
if (!tPart) return false;
const t = Number(tPart.slice(2));
if (!Number.isInteger(t)) return false;
// Both directions: a far-future timestamp is a forgery signal too.
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - t) > 300) return false;
// The signed material is "<timestamp>.<raw body>".
const expected = crypto
.createHmac("sha256", secret)
.update(`${t}.${rawBody}`)
.digest("hex");
const expectedBuf = Buffer.from(expected, "hex");
// The header may carry several v1= entries while a secret rotation is
// in flight; a delivery is valid if ANY of them matches.
return parts
.filter((p) => p.startsWith("v1="))
.some((p) => {
const candidate = Buffer.from(p.slice(3), "hex");
return (
candidate.length === expectedBuf.length &&
crypto.timingSafeEqual(candidate, expectedBuf)
);
});
}- Compare digests in constant time. A plain
===leaks how much of a forged signature was correct, one byte at a time. - Reject a timestamp more than 300 seconds from now, in both directions — a one-sided check accepts a far-future signature forever.
Retries and auto-disable
- Answer
2xxas soon as you have durably accepted the event, and do the work afterwards — a slow handler burns the delivery timeout and gets retried. - Anything else is retried on a backoff ladder, except a
4xxother than408or429, which is treated as permanent — it would fail the same way next time. - An endpoint that fails continuously is switched off automatically and the team owner is emailed. Read
disabled_reasonon the endpoint to tell that apart from someone turning it off, and re-enable withPATCH { "active": true }. - The delivery log (
GET /webhooks/{webhookId}/deliveries) records the status, your response body and the error for every attempt. It does not include the event payload — fetch the object from its own endpoint using the event id if you need it.
Replaying a delivery
POST /webhooks/{webhookId}/deliveries/{deliveryId}/replay queues the original payload for re-delivery as a new delivery row, so the log shows both attempts. The X-Mailneo-Event-Id is unchanged — a consumer that deduplicates on it correctly treats the replay as the event it already saw — while the timestamp and signature are freshly computed: re-sending the original signature would either fall outside the tolerance or prove that a captured signature stays valid forever. Idempotency-Key is required, because a retried replay that already ran must not deliver twice; replaying to a disabled endpoint answers 409 resource_state_invalid — re-enable it first. See errors and idempotency.