Webhooks

Get a POST to your own endpoint the moment an email is sent, delivered, opened, bounced or marked as spam — no polling.

1

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

2

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.

.env
CMDSEND_WEBHOOK_SECRET=whsec_your_signing_secret
3

Verify 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.

app/webhooks/cmdsend/route.ts
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 });
}
4

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

EventSent when
email.sentAccepted by the mail provider and on its way. The first event for every send.
email.deliveredAccepted by the recipient's mail server.
email.openedThe recipient opened the message. Requires open tracking and is best-effort — many clients block the pixel.
email.clickedThe recipient clicked a tracked link. Carries the link in the payload.
email.bouncedDelivery permanently or temporarily failed. Carries bounce_type and bounce_subtype.
email.complainedThe recipient marked it as spam. Stop mailing this address.
email.failedThe 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

ParameterDescription
cmdsend-signature
stringrequired
HMAC signature and timestamp: t=<unix>,v1=<hex>. Verify this before trusting the body.
cmdsend-event-type
stringrequired
The event type, so you can route without parsing the body first.
cmdsend-delivery-id
stringrequired
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_at rather 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.