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, an app deploys, a domain verifies. 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 template could not be rendered for this recipient
email.domain_verifiedA sending domain finished DNS verification
email.sending_pausedA send allowance ran out and sending was paused

Platform

EventWhen it fires
app.deployedA deployment finished successfully
app.failedA deployment failed to build or start
domain.verifiedA domain passed verification
domain.expiredA domain registration lapsed
server.runningA server finished provisioning
server.errorA server entered an error state
member.invitedSomeone was invited to the project
member.joinedAn invitation was accepted

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"
  }
}

Alongside these headers:

HeaderContents
X-Datablock-Signaturet=<unix seconds>,v1=<hex hmac-sha256>
X-Datablock-Delivery-IdUnique per delivery — use it to dedupe
X-Datablock-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.

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