Cosmoner Docs
Guides

Receiving Webhooks

Subscribe to email and platform events, verify the signature, and handle retries.

A webhook endpoint is an HTTPS URL of yours that we POST to whenever something happens in your project — an email is delivered or bounces, a deployment goes live or fails. It is the alternative to polling the API on a timer.

Create one under Webhooks in your project, or with the Webhooks API.

Events

Events are named family.thing_that_happened. Subscribe to as few or as many as you like.

Email

EventWhen it fires
email.sentAccepted by us and handed to the mail servers
email.deliveredThe receiving mail server accepted the message
email.delivery_delayedTemporarily undeliverable; still being retried
email.bouncedRejected by the receiving server
email.complainedThe recipient marked the message as spam
email.openedThe recipient opened the message
email.clickedThe recipient clicked a tracked link
email.rejectedWe refused to send the message
email.rendering_failedThe mail servers could not build the message after accepting it ¹

¹ This is about assembling the message for delivery, not about email templates. A template is rendered before the email is handed on, so a template error — such as variables that do not match — is returned by the send call itself and never arrives as this event.

Apps

EventWhen it fires
app.deployedA deployment went live: the new version is serving
app.failedA deployment failed — the build, the rollout, or the app starting

Every deployment counts: the first one, a redeploy, an automatic deploy after a push, a deploy through the API, and a rollback. A deployment that is overtaken by a newer one before it finishes sends neither.

These are all the events we send today. An endpoint created earlier may list other event names as well; it keeps them, and nothing is sent for them.

The request

Every delivery is a POST with a JSON body in the same envelope:

{
  "id": "clw9x8y7z",
  "type": "email.bounced",
  "createdAt": "2026-07-28T09:11:28.000Z",
  "data": {
    "messageId": "0100018f…",
    "email": "[email protected]",
    "from": "[email protected]",
    "domain": "yourdomain.com",
    "subject": "Your receipt",
    "timestamp": "2026-07-28T09:11:27.000Z",
    "bounceType": "Permanent",
    "bounceSubType": "General",
    "complaintFeedbackType": null,
    "linkClicked": null,
    "remoteMtaIp": "203.0.113.10"
  }
}

App deployment events

app.deployed and app.failed carry the deployment in the same shape as List deployments returns it, with the app and project it belongs to:

{
  "id": "clx2k4m6p",
  "type": "app.failed",
  "createdAt": "2026-10-25T09:03:12.410Z",
  "data": {
    "projectId": "clv1a2b3c",
    "app": { "id": "clw1d2e3f", "name": "api" },
    "deployment": {
      "id": "clw4g5h6i",
      "phase": "ERROR",
      "cause": "registry push",
      "imageRef": "registry.cosmoner.com/acme/api:v2",
      "imageDigest": "sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08",
      "error": "The new version never became available: 0 of 1 replicas are ready 15 minutes after the rollout started.",
      "startedAt": "2026-10-25T08:47:02.118Z",
      "finishedAt": "2026-10-25T09:03:12.402Z"
    }
  }
}

deployment.phase is ACTIVE on app.deployed and ERROR on app.failed, where deployment.error says what went wrong. deployment.cause says why the deployment happened — rollback for a rollback.

Each outcome is sent once. A deployment that is retried keeps its id, so the same deployment.id can arrive in an app.failed and later in an app.deployed; finishedAt says which came last.

Headers

Alongside the body:

HeaderContents
X-Cosmoner-Signaturet=<unix seconds>,v1=<hex hmac-sha256>
X-Cosmoner-Delivery-IdUnique per delivery — use it to dedupe
X-Cosmoner-EventThe event type, for routing without parsing

Verifying the signature

Verify every request before acting on it. Anyone can POST JSON at your URL; the signature is what proves the request came from us.

The signed value is the timestamp and the raw request body joined by a dot: "{timestamp}.{body}", HMAC-SHA256'd with your signing key. Comparing against the raw body matters — re-serializing the parsed JSON can change the bytes and break the comparison.

import crypto from "node:crypto";

/** Returns whether the request really came from us and is recent. */
function verify(rawBody: string, header: string, secret: string): boolean {
  const parts = Object.fromEntries(
    header.split(",").map((p) => {
      const [k, ...rest] = p.split("=");
      return [k, rest.join("=")];
    }),
  );

  const timestamp = Number(parts.t);
  if (!Number.isFinite(timestamp) || !parts.v1) return false;

  // Reject anything older than five minutes so a captured request cannot be
  // replayed at you later.
  if (Math.abs(Date.now() / 1000 - timestamp) > 300) return false;

  const expected = crypto
    .createHmac("sha256", secret)
    .update(`${timestamp}.${rawBody}`)
    .digest("hex");

  const a = Buffer.from(expected);
  const b = Buffer.from(parts.v1);
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}

In Express, reach for express.raw({ type: "application/json" }) on the webhook route so req.body is the untouched bytes.

The SDKs ship this check for JavaScript, Python and PHP, so you do not have to write it yourself.

Responding

Answer 2xx as soon as you have stored the event. Anything else — a 4xx, a 5xx, a timeout — is treated as a failure and retried.

Do the real work afterwards, not before responding. We give up on a request after 10 seconds, so a slow handler turns a successful delivery into a retry even though you received the event.

Retries and duplicates

A failed delivery is retried up to 5 times, with a growing delay: 30 seconds, then 2 minutes, 10 minutes, and 30 minutes. After that the delivery is marked failed and stays in the log.

Because a delivery can be retried after your handler already ran, your endpoint must be idempotent. X-Cosmoner-Delivery-Id is stable across the retries of one delivery — record it and ignore ids you have already processed.

Automatic pausing

If 10 deliveries in a row exhaust every retry, the endpoint is paused and the project's biller is emailed. A single successful delivery at any point resets that count, so an endpoint that is merely flaky is never paused.

Pausing stops delivery attempts — it does not drop events. New events keep queuing while the endpoint is down. When you resume it from the Webhooks page or the API, everything that piled up is delivered.

This is why an endpoint that is down for a deploy needs no action from you: it either recovers before the tenth failure, or it pauses and you resume it once the fix is out.

Rotating the signing key

Rotating generates a new key and invalidates the old one immediately, so update your receiver in the same change. If you cannot deploy both at once, have your receiver accept either key for the duration of the switch.

On this page