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"
  }'
The response contains 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 requires contacts:read or suppressions:read on 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 headers are 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

HeaderMeaning
X-Mailneo-Signaturet=<unix seconds>,v1=<hex>. The hex is HMAC-SHA256 of <timestamp>.<raw body> keyed with your secret.
X-Mailneo-TimestampThe same unix-seconds value that is inside the signature.
X-Mailneo-EventThe catalog event name, so you can route without parsing the body.
X-Mailneo-Event-IdDeduplicate 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-IdThis attempt. A retry or replay of the same event gets a new one.
X-Mailneo-Webhook-IdThe endpoint being delivered to.
X-Mailneo-Api-VersionThe dated API version the payload is shaped by.
X-Mailneo-Replaytrue 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 2xx as 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 4xx other than 408 or 429, 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_reason on the endpoint to tell that apart from someone turning it off, and re-enable with PATCH { "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.