Webhooks
Get a POST to your own endpoint the moment an email is sent, delivered, opened, bounced or marked as spam — no polling.
Add an endpoint
In Dashboard → Webhooks → Add endpoint, enter a public https URL and pick the events you want. Or create it from the API:
curl -X POST https://api.cmdsend.com/v1/webhooks \
-H "Authorization: Bearer $CMDSEND_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"url": "https://yourapp.com/webhooks/cmdsend",
"description": "Production API",
"event_types": ["email.delivered", "email.bounced", "email.complained"]
}'Omit event_types to subscribe to everything.
Store the signing secret
Creating an endpoint returns a secret starting with whsec_. It is shown once — copy it into your environment now. It is what proves a request came from cmdsend and not from someone who guessed your URL.
CMDSEND_WEBHOOK_SECRET=whsec_your_signing_secretVerify the signature, then handle the event
Your endpoint must verify cmdsend-signature against the raw request body. Parsing the JSON first and re-serialising it changes the bytes and the signature will not match.
import crypto from 'node:crypto';
const SECRET = process.env.CMDSEND_WEBHOOK_SECRET!;
const TOLERANCE_SECONDS = 300;
function verify(rawBody: string, header: string | null): boolean {
if (!header) return false;
const parts = Object.fromEntries(
header.split(',').map((p) => {
const i = p.indexOf('=');
return [p.slice(0, i).trim(), p.slice(i + 1).trim()];
})
);
const timestamp = Number(parts.t);
if (!Number.isFinite(timestamp) || !parts.v1) return false;
// Reject anything old enough to be a replay of a captured request.
if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) return false;
const expected = crypto
.createHmac('sha256', SECRET)
.update(`${timestamp}.${rawBody}`)
.digest('hex');
const a = Buffer.from(expected);
const b = Buffer.from(parts.v1);
// timingSafeEqual throws when the lengths differ, so check that first.
return a.length === b.length && crypto.timingSafeEqual(a, b);
}
export async function POST(req: Request) {
const rawBody = await req.text(); // raw, not req.json()
if (!verify(rawBody, req.headers.get('cmdsend-signature'))) {
return new Response('Invalid signature', { status: 401 });
}
const event = JSON.parse(rawBody);
switch (event.type) {
case 'email.bounced':
await suppress(event.data.to, event.data.bounce_type);
break;
case 'email.complained':
await unsubscribe(event.data.to);
break;
}
// Answer 2xx quickly — do the slow work in a background job.
return new Response('ok', { status: 200 });
}Send a test event
Use Send test on the endpoint in your dashboard, or call the API. It delivers a sample email.delivered so you can confirm your receiver works before waiting on real mail.
curl -X POST https://api.cmdsend.com/v1/webhooks/$ENDPOINT_ID/test \
-H "Authorization: Bearer $CMDSEND_API_KEY"The result — including the status code your endpoint returned — appears under Deliveries on the endpoint.
Event types
| Event | Sent when |
|---|---|
email.sent | Accepted by the mail provider and on its way. The first event for every send. |
email.delivered | Accepted by the recipient's mail server. |
email.opened | The recipient opened the message. Requires open tracking and is best-effort — many clients block the pixel. |
email.clicked | The recipient clicked a tracked link. Carries the link in the payload. |
email.bounced | Delivery permanently or temporarily failed. Carries bounce_type and bounce_subtype. |
email.complained | The recipient marked it as spam. Stop mailing this address. |
email.failed | The send never left cmdsend — blocked by the content gateway, or rejected by the provider. |
Payload
Every event has the same envelope. data always identifies the email, plus fields specific to the event.
{
"type": "email.bounced",
"created_at": "2026-09-10T12:00:03.000Z",
"data": {
"email_id": "a1b2c3d4-...",
"from": "Acme <billing@yourdomain.com>",
"to": "user@example.com",
"subject": "Your invoice is ready",
"bounce_type": "Permanent",
"bounce_subtype": "General"
}
}Headers
| Parameter | Description |
|---|---|
cmdsend-signaturestringrequired | HMAC signature and timestamp: t=<unix>,v1=<hex>. Verify this before trusting the body. |
cmdsend-event-typestringrequired | The event type, so you can route without parsing the body first. |
cmdsend-delivery-idstringrequired | Unique id for this delivery attempt series. Use it to make your handler idempotent. |
Retries
Any response outside 2xx — including a redirect, which we never follow — counts as a failure and is retried 6 times with exponential backoff, spread over roughly 5 hours. A request that gets no response within 10 seconds is treated the same way.
An endpoint whose deliveries keep failing is disabled automatically after 20 consecutive exhausted events, and the reason is shown in your dashboard. Re-enabling it clears the counter. A single success resets it too, so a brief outage never counts toward being switched off.
Writing a reliable receiver
- Reply 2xx fast. Acknowledge first and do the real work in a background job — a slow handler turns into a timeout and a retry.
- Be idempotent. A retry can deliver the same event twice. Deduplicate on
cmdsend-delivery-id. - Don't assume order. Independent events can arrive out of order; use
created_atrather than arrival order. - Always verify the signature. Your URL is not a secret — the signature is what makes a request trustworthy.
Rotating the secret
Rotating issues a new secret and takes effect immediately — there is no overlap window, so update your receiver first, then rotate. If a secret leaks, rotate straight away and accept the brief gap.
Local development
Endpoints must be publicly reachable over https, so localhost will not be accepted — private and internal addresses are refused to prevent them being used to reach inside our network. Use a tunnel (ngrok, Cloudflare Tunnel) to get a public URL while developing.
Full endpoint list in the Webhooks API reference. Every event is also queryable on the email itself if you would rather poll.