KoltrixDocs

Webhooks

A webhook is an HTTPS address of yours that Koltrix calls when something happens to the mail you send: it was handed off, it bounced, it was opened, a link in it was clicked. Use webhooks to update your own records without polling the API.

Every request is signed with a secret that only you and Koltrix know, so you can check it really came from Koltrix.

Add an endpoint

Owners and admins add endpoints under Settings → Webhooks (also linked from Developers). Paste an https:// URL and click Add. The endpoint starts receiving every message.* event straight away.

Each endpoint has its own signing secret, a string that starts with whsec_. Store it with your handler's other secrets; you need it to verify that a request came from Koltrix.

Koltrix records every delivery attempt with the status code your server returned and the first 2 KB of its response.

Events

EventWhen it is sent
message.sentKoltrix's outbound mail server accepted the message for delivery.
message.bouncedThe message was rejected when Koltrix handed it off. A permanent rejection also adds the address to your suppression list.
message.openedThe recipient's mail client loaded the open-tracking image. Automatic image fetching by mail providers is filtered out.
message.clickedThe recipient clicked a tracked link.

message.sent and message.bounced are sent for mail you send with the REST API, the SMTP relay and broadcasts. message.opened and message.clicked are sent for any message Koltrix tracks.

The event names message.delivered, message.complained and message.unsubscribed are reserved: an endpoint may be subscribed to them, but Koltrix doesn't send them yet.

The request

Each event is a POST with a JSON body:

POST /hooks/koltrix HTTP/1.1
Host: app.acme.com
Content-Type: application/json
User-Agent: Koltrix-Webhook/1.0
X-Koltrix-Event: message.opened
X-Koltrix-Signature: sha256=5d41402abc4b2a76b9719d911017c592ae0e1a2c9f5c1c8e0f6f0f1a3b4c5d6e
 
{"event":"message.opened","from":"[email protected]","message_id":"6f1c2a9e-3b7d-4e41-9a52-0c8d1f3e7b20","subject":"Your receipt for order 1042","timestamp":1759413600,"to":"[email protected]"}
HeaderValue
X-Koltrix-EventThe event name, the same as event in the body.
X-Koltrix-Signaturesha256= followed by the hex HMAC-SHA256 of the raw body, keyed with your signing secret.
User-AgentKoltrix-Webhook/1.0

Payloads

Every body has event and timestamp (Unix seconds, when the event was sent). message_id is the same id the API returns for the message.

{
  "event": "message.sent",
  "timestamp": 1759413601,
  "message_id": "6f1c2a9e-3b7d-4e41-9a52-0c8d1f3e7b20",
  "from": "[email protected]",
  "subject": "Your receipt for order 1042"
}

message.sent and message.bounced don't include the recipient; look it up by message_id with GET /api/v2/messages/:id if you need it. message.clicked doesn't include the link; the message's event timeline (GET /api/v2/messages/:id/events) has it.

A test event is a message.sent event with "test": true, a message_id that starts with test_ and placeholder addresses. Ignore events with "test": true in production code.

Verify the signature

Compute HMAC-SHA256 over the raw request body with your signing secret, hex-encode it, put sha256= in front and compare it with X-Koltrix-Signature in constant time. Reject the request if they differ.

The timestamp is inside the signed body, so it can't be changed without breaking the signature. Rejecting events more than a few minutes old stops an old request from being replayed at you.

import crypto from "node:crypto";
import express from "express";
 
const app = express();
 
// express.raw keeps the exact bytes; express.json would re-serialise them.
app.post("/hooks/koltrix", express.raw({ type: "application/json" }), (req, res) => {
  const expected =
    "sha256=" +
    crypto.createHmac("sha256", process.env.KOLTRIX_WEBHOOK_SECRET!).update(req.body).digest("hex");
  const given = req.get("X-Koltrix-Signature") ?? "";
  const ok =
    given.length === expected.length &&
    crypto.timingSafeEqual(Buffer.from(given), Buffer.from(expected));
  if (!ok) return res.status(401).send("bad signature");
 
  const event = JSON.parse(req.body.toString("utf8"));
  if (Math.abs(Date.now() / 1000 - event.timestamp) > 300) return res.status(400).send("stale");
 
  res.sendStatus(200); // answer first, then do the work
  handle(event);
});

If verification fails for every request, the cause is almost always that the body was parsed and re-serialised before you hashed it. Hash the bytes exactly as they arrived.

Delivery

  • Koltrix waits up to 8 seconds for your endpoint to answer. Any 2xx status counts as delivered.
  • Events are sent as they happen, so they can arrive out of order, and an open can arrive before the matching message.sent.
  • Answer quickly (record the event and return 200) and do slow work afterwards.

Write your handler so the same event can be processed twice without harm, for example by remembering message_id + event. That keeps it safe when retries are added.

  • Sending email: the message ids these events refer to.
  • Errors: what the API returns when something goes wrong.
  • Troubleshooting: events that don't arrive or don't verify.