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.
| Event | When it fires |
|---|---|
email.sent | Accepted by us and handed to the mail servers |
email.delivered | The receiving mail server accepted the message |
email.delivery_delayed | Temporarily undeliverable; still being retried |
email.bounced | Rejected by the receiving server |
email.complained | The recipient marked the message as spam |
email.opened | The recipient opened the message |
email.clicked | The recipient clicked a tracked link |
email.rejected | We refused to send the message |
email.rendering_failed | The 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
| Event | When it fires |
|---|---|
app.deployed | A deployment went live: the new version is serving |
app.failed | A 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:
| Header | Contents |
|---|---|
X-Cosmoner-Signature | t=<unix seconds>,v1=<hex hmac-sha256> |
X-Cosmoner-Delivery-Id | Unique per delivery — use it to dedupe |
X-Cosmoner-Event | The 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.
Email Templates
Write transactional emails once in the control panel, in code with React Email or in the visual editor, publish them as versions, and send them by alias through the API.
Connect Your Cloudflare Account
Connect a project-owned Cloudflare account to manage its DNS zones and simplify app custom-domain setup.