Webhooks

Register a URL per mail server and CamelMailer POSTs a small signed JSON body every time an outgoing message changes state. Read the delivery lifecycle without polling the API.

Create a webhook

Webhooks are a server resource. Add one in the dashboard (Server → Webhooks) or through the management API. Pick the events it receives, or send an empty list to subscribe to all of them.

terminal
curl -X POST \
  https://mail-admin.example.com/api/v2/admin/organizations/acme/servers/production/webhooks \
  -H "X-Admin-API-Key: $ADMIN_KEY" \
  -H "Content-Type: application/json" \
  -d '{
        "name": "delivery-events",
        "url": "https://app.example.com/hooks/camelmailer",
        "events": ["MessageSent", "MessageDeliveryFailed"],
        "sign": true
      }'

Signing is on by default. Add a headers object for a shared secret such as an Authorization bearer token, which rides on every delivery. Toggle a webhook with the /enable and /disable endpoints during incident response without losing its configuration.

Events

CamelMailer fires four events, all about the fate of an outgoing message. These names are the source of truth. The API rejects any other value at registration time and lists the valid ones back to you.

EventFires whenStatus recorded
MessageSentThe recipient mail server accepted the message.Sent
MessageDelayedA delivery attempt hit a temporary (4xx) failure and another try is scheduled.SoftFail
MessageDeliveryFailedDelivery failed for good: a hard (5xx) rejection, or the retries ran out.HardFail
MessageHeldAn outgoing message was held before sending. Today this fires when the recipient sits on the server suppression list.Held

One message can produce several events over its life. A message that is deferred twice and then delivered emits two MessageDelayed events followed by one MessageSent. There is no separate bounce, complaint, open, or click webhook today. Watch MessageDeliveryFailed for hard bounces, and read open and click activity through tracking on the sending side.

Payload shape

Every delivery carries the same envelope. The top level names the event and holds a per-delivery uuid and a Unix timestamp in seconds. The event data sits under payload, and the payload object is identical for all four events. Only event and the human-readable details string change.

POST body
{
  "event": "MessageSent",
  "timestamp": 1720000000,
  "uuid": "6f1c2b7e-2a4d-4c9e-9f3a-6d8b0e1f2a3c",
  "payload": {
    "message": {
      "id": 1234,
      "token": "AbCdEf123456",
      "rcpt_to": "recipient@example.com",
      "mail_from": "sender@yourdomain.com",
      "scope": "outgoing",
      "bounce": false
    },
    "details": "message accepted by the remote server"
  }
}

The same values arrive as headers: X-CamelMailer-Event matches event, and X-CamelMailer-UUID matches uuid. That uuid is stable across retries of one event, so it doubles as an idempotency key. Test deliveries add a top-level test: true. Real deliveries never carry it.

Verify the signature

When signing is enabled, CamelMailer signs the exact request body and sends the signature in X-CamelMailer-Signature. The algorithm is RSA PKCS#1 v1.5 over a SHA-256 digest, standard base64 of the raw signature bytes, computed over the complete body byte for byte with the installation signing key.

That signing key is the installation RSA key, the same one CamelMailer uses for DKIM. Its public half is the value published as your DKIM p= DNS record. There is no dedicated endpoint that serves the webhook public key, so export the public half once and hand it to your receiver:

terminal
openssl rsa -in /path/to/signing.key -pubout -out camelmailer-webhooks.pub

Take the raw request body exactly as received, base64-decode the signature header, and check it against the body with the public key. Any reformatting of the JSON changes the bytes and breaks the signature, so verify before you parse.

verify.js (Node)
const crypto = require("crypto");

// Give Express the raw body so the bytes match what was signed:
//   app.post("/hooks/camelmailer", express.raw({ type: "application/json" }), handler)
function verify(rawBody, signatureHeader, publicKeyPem) {
  const verifier = crypto.createVerify("RSA-SHA256");
  verifier.update(rawBody); // the exact bytes received, not a re-serialized object
  verifier.end();
  return verifier.verify(publicKeyPem, signatureHeader, "base64");
}
Treat every webhook body as untrusted input. Verify the signature, answer fast with a 2xx, and do the real work asynchronously. Failed deliveries retry with backoff, so a slow handler only makes duplicate work for itself. A missing signature means signing is off for that webhook or the installation has no key on disk. Reject it when you rely on signing.

Delivery and retries

Each event fans out to every enabled webhook that subscribes to it, and each delivery becomes a row in a per-server queue that the worker drains.

  • Queue. Deliveries are picked up one at a time with FOR UPDATE SKIP LOCKED, so several workers run in parallel and no request goes out twice.
  • Success. Any 2xx response completes the delivery and clears it from the queue.
  • Backoff. A non-2xx response, a connection error, or a timeout schedules a retry with exponential backoff of 2^attempts minutes, capped at 24 hours. The schedule runs 1, 2, 4, 8, 16, 32, 64, 128, and 256 minutes.
  • Giving up. A delivery is attempted at most 10 times. After the tenth failure the worker logs a warning and drops the request.
  • Audit log. Every attempt records the event, URL, attempt number, HTTP status, success flag, and the first 2 KB of the response body. Custom header values are secrets and stay out of the log.

Because retries span hours, keep your receiver idempotent. Respond 2xx as soon as you have durably accepted the event, then use the uuid to detect a repeat of one you already handled.

Debugging

Every attempt shows up with its request and response details in the dashboard under Server → Webhooks → Request log. To confirm a receiver before real traffic flows, use the test send (POST …/webhooks/{id}/test with an event name). It delivers one sample payload synchronously with the same headers and signature, marks it test: true, and writes nothing to the queue or the audit log.

The full walkthrough with a working receiver, including raw-body parsing and async processing, is in How-to: receive webhooks.