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
| Event | When it is sent |
|---|---|
message.sent | Koltrix's outbound mail server accepted the message for delivery. |
message.bounced | The message was rejected when Koltrix handed it off. A permanent rejection also adds the address to your suppression list. |
message.opened | The recipient's mail client loaded the open-tracking image. Automatic image fetching by mail providers is filtered out. |
message.clicked | The 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]"}| Header | Value |
|---|---|
X-Koltrix-Event | The event name, the same as event in the body. |
X-Koltrix-Signature | sha256= followed by the hex HMAC-SHA256 of the raw body, keyed with your signing secret. |
User-Agent | Koltrix-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"
}{
"event": "message.bounced",
"timestamp": 1759413601,
"message_id": "6f1c2a9e-3b7d-4e41-9a52-0c8d1f3e7b20",
"from": "[email protected]",
"subject": "Your receipt for order 1042",
"error": "550 5.1.1 <[email protected]>: Recipient address rejected"
}{
"event": "message.opened",
"timestamp": 1759413600,
"message_id": "6f1c2a9e-3b7d-4e41-9a52-0c8d1f3e7b20",
"to": "[email protected]",
"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);
});import hashlib, hmac, json, os, time
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ["KOLTRIX_WEBHOOK_SECRET"].encode()
@app.post("/hooks/koltrix")
def koltrix_hook():
raw = request.get_data() # the exact bytes, before any JSON parsing
expected = "sha256=" + hmac.new(SECRET, raw, hashlib.sha256).hexdigest()
if not hmac.compare_digest(expected, request.headers.get("X-Koltrix-Signature", "")):
abort(401)
event = json.loads(raw)
if abs(time.time() - event["timestamp"]) > 300:
abort(400)
handle(event)
return "", 200func koltrixHook(w http.ResponseWriter, r *http.Request) {
raw, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
if err != nil {
http.Error(w, "read", http.StatusBadRequest)
return
}
mac := hmac.New(sha256.New, []byte(os.Getenv("KOLTRIX_WEBHOOK_SECRET")))
mac.Write(raw)
expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(expected), []byte(r.Header.Get("X-Koltrix-Signature"))) {
http.Error(w, "bad signature", http.StatusUnauthorized)
return
}
var event struct {
Event string `json:"event"`
Timestamp int64 `json:"timestamp"`
MessageID string `json:"message_id"`
}
if json.Unmarshal(raw, &event) != nil || time.Since(time.Unix(event.Timestamp, 0)).Abs() > 5*time.Minute {
http.Error(w, "stale or malformed", http.StatusBadRequest)
return
}
w.WriteHeader(http.StatusOK)
go handle(event.Event, event.MessageID)
}<?php
$raw = file_get_contents("php://input");
$expected = "sha256=" . hash_hmac("sha256", $raw, getenv("KOLTRIX_WEBHOOK_SECRET"));
$given = $_SERVER["HTTP_X_KOLTRIX_SIGNATURE"] ?? "";
if (!hash_equals($expected, $given)) {
http_response_code(401);
exit;
}
$event = json_decode($raw, true);
if (abs(time() - $event["timestamp"]) > 300) {
http_response_code(400);
exit;
}
http_response_code(200);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
2xxstatus 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.
Related
- 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.