Cakemail
Webhooks

Using webhooks

A webhook sends an HTTP POST to your URL each time an event happens in your account: an email bounces, a contact unsubscribes, a campaign is scheduled. This guide covers creating and managing webhooks, the payload your URL receives, checking that a delivery comes from the platform, and the three classes of events.

  • Event catalog: every event you can subscribe to, and when it fires.
  • Compliance events: the exact payloads of bounces, spam complaints, unsubscribes, and refused or failed sends.

Creating a webhook

A webhook subscribes one URL to one event. To receive several events, create one webhook per event; they can share a URL.

TerminalCode
curl --request POST \ --url 'https://api.cakemail.dev/webhooks' \ --header 'Authorization: Bearer <access token>' \ --header 'Content-Type: application/json' \ --data '{ "event": "Email.Bounced", "url": "https://hooks.example.com/platform-events", "rate_limit": 10, "rate_limit_period": "second" }'

The response (201) returns the webhook:

JSONCode
{ "id": "<webhook_id>", "data": { "id": "<webhook_id>", "status": "active", "event": "Email.Bounced", "url": "https://hooks.example.com/platform-events", "archived_at": null, "rate_limit": 10, "rate_limit_period": "second" } }
FieldRequiredDescription
eventYesThe event, such as Email.Bounced. See the event catalog.
urlYesWhere deliveries go: an http or https URL.
rate_limitNoThe most deliveries per period. The default is 50, and the maximum is 50 per second or 3,000 per minute.
rate_limit_periodNosecond (the default) or minute.

Sub-accounts. Webhook requests act on the account of the access token. A partner or an organization can act on a sub-account by adding ?account_id=<account_id> to any webhook request. That webhook then receives the sub-account's events.

Access tokens. With a personal access token, listing and showing webhooks needs the webhooks:read scope, and creating or changing them needs webhooks:write.

Errors. Error responses have the form {"detail": [{"msg": "…", "type": "…"}]}.

StatusWhy
422A field is missing or invalid, the rate limit is above the maximum, or the event is a high-volume event and the URL's domain isn't approved (High volume events can only be registered with approved webhook domains).
400, code 2600Webhook not found: the id doesn't exist, or belongs to another account.
400, code 2604The new URL of a high-volume webhook isn't on an approved domain.

See Create a webhook in the API reference for every field.

Managing webhooks

RequestWhat it does
GET /webhooksLists the account's webhooks. Add with_archived=true to include archived ones. Paginate with page, per_page and with_count.
GET /webhooks/{webhook_id}Shows one webhook, and the account's signing key.
PATCH /webhooks/{webhook_id}Changes url, or rate_limit and rate_limit_period together. The event can't be changed: create another webhook.
POST /webhooks/{webhook_id}/archiveStops deliveries.
POST /webhooks/{webhook_id}/unarchiveStarts them again.

Receiving events

Each delivery is a POST with a JSON body, a Content-Type: application/json header and an x-signature header (see Securing your webhooks).

The payload envelope

Every event has the same envelope. This is a campaign bounce:

JSONCode
{ "id": "570bfeff-8a80-5f41-bb57-f2f343e49381", "event": "Email.Bounced", "account_id": 1234, "account_lineage": null, "ip": "192.0.2.20", "user": { "id": null, "email": null, "account_id": null }, "created_on": 1790431393, "data": { "bounce": { "id": null, "dsn_message": "smtp; 550 5.1.1 <jane@example.com>: Recipient address rejected: User unknown", "source_ip": "192.0.2.10", "category": "bounce_hb" }, "campaign": { "type": "campaign", "id": 4321, "name": "September newsletter" }, "context": { "timestamp": null, "user_agent": null, "ip": null }, "contact": { "id": 5678, "email": "jane@example.com", "status": null }, "list": { "id": 111, "name": "Newsletter subscribers" }, "segment": { "id": null, "name": null, "query": null } } }
FieldTypeDescription
idstringThe event's unique id. It stays the same when an event is delivered again, so deduplicate on it.
eventstringThe event name.
account_idintegerThe account the event belongs to.
account_lineagenullAlways null.
ipstringWhere the event came from. For an event caused by an API request (an account, campaign, sender, user or export event), it's the address that made the request. For an event the platform records itself, such as a bounce, a complaint or an unsubscribe, it's a platform server, never the recipient: when the recipient's address is known, it's in data.context.ip.
userobjectThe id, email and account_id of the user whose action caused the event, when the platform knows it. They're null when no user acted, as for a bounce, a complaint or an unsubscribe, and can be null for an event caused by an API request.
created_onintegerWhen this delivery was created, in Unix seconds. It isn't when the event happened, and it changes when a delivery is repeated. Email API events have the event time in data.event_time.
dataobjectThe event's details. Its shape depends on the event and on how the email was sent: see Compliance events.

Handling deliveries

  • Answer with a 2xx status quickly, and do the work afterwards.
  • The same event can arrive more than once. Deduplicate on id.
  • Events can arrive in any order. When data.event_time or data.context.timestamp is present, use it to order them.
  • A webhook's rate_limit caps how fast deliveries reach your URL.

Securing your webhooks

The x-signature header of each delivery is the HMAC-SHA256 of the raw request body, keyed with your account's signing key, written as lowercase hexadecimal. Compute it and compare: a match proves that the platform sent this exact body.

  1. Get the signing key. GET /webhooks/{webhook_id} returns it in signature.key. An account has one key, shared by all its webhooks.

    TerminalCode
    curl --request GET \ --url 'https://api.cakemail.dev/webhooks/<webhook_id>' \ --header 'Authorization: Bearer <access token>'
    JSONCode
    { "id": "<webhook_id>", "signature": { "key": "<signing key>", "hash_function": "sha256" }, "data": { "id": "<webhook_id>", "status": "active", "event": "Email.Bounced", "url": "https://hooks.example.com/platform-events", "archived_at": null, "rate_limit": 10, "rate_limit_period": "second" } }
  2. Verify the body exactly as received, before parsing it. Parsing and re-serializing the JSON changes the bytes, and the signature no longer matches.

Node.js:

JavascriptCode
import { createHmac, timingSafeEqual } from "node:crypto"; // rawBody: the request body exactly as received (a Buffer or a string), before any JSON parsing. // signature: the x-signature header. signingKey: signature.key from GET /webhooks/{webhook_id}. export function isValidSignature(rawBody, signature, signingKey) { const expected = Buffer.from(createHmac("sha256", signingKey).update(rawBody).digest("hex")); const received = Buffer.from(String(signature ?? "")); return received.length === expected.length && timingSafeEqual(received, expected); }

With Express, read the raw body:

JavascriptCode
import express from "express"; import { isValidSignature } from "./verify.mjs"; const app = express(); app.post("/platform-events", express.raw({ type: "application/json" }), (req, res) => { if (!isValidSignature(req.body, req.get("x-signature"), process.env.WEBHOOK_SIGNING_KEY)) { return res.sendStatus(401); } const event = JSON.parse(req.body); // Skip event.id if you've seen it, queue the work, and answer right away. res.sendStatus(204); });

Python:

Code
import hashlib import hmac def is_valid_signature(raw_body: bytes, signature: str, signing_key: str) -> bool: """raw_body: the request body exactly as received, before any JSON parsing. signature: the x-signature header. signing_key: signature.key from GET /webhooks/{webhook_id}.""" expected = hmac.new(signing_key.encode("utf-8"), raw_body, hashlib.sha256).hexdigest() return hmac.compare_digest(expected.encode("utf-8"), (signature or "").encode("utf-8"))

With Flask:

Code
import os from flask import Flask, abort, request from verify import is_valid_signature app = Flask(__name__) @app.post("/platform-events") def platform_events(): signature = request.headers.get("x-signature", "") if not is_valid_signature(request.get_data(), signature, os.environ["WEBHOOK_SIGNING_KEY"]): abort(401) event = request.get_json() # Skip event["id"] if you've seen it, queue the work, and answer right away. return "", 204

Event classes

ClassEventsWhat it needs
StandardAccount, campaign, export, list, segment, sender, suppression and user eventsAny URL.
ComplianceEmail.Bounced, Email.ReportedAsSpam, Email.Unsubscribed, Email.GlobalUnsubscribed, Email.Rejected, Email.Error, Contact.RemovedAny URL. Their payloads are in Compliance events.
High-volumeContact.Added, Contact.Updated, Email.Sent, Email.Opened, Email.Clicked, Email.Submitted, Email.Queued, Email.Processing, Email.DeliveredA URL on an approved domain. Contact support to have a domain approved.

Which events fire also depends on how you send: see the event catalog.

Limitations

  • A webhook subscribes to one event, and its event can't be changed.
  • A failed delivery isn't guaranteed to be retried. If your URL was unavailable, reconcile from the API.
  • Payloads are documented for compliance events and SuppressedEmail.Added only.
Last modified on