Webhooks
Real-time delivery and list events delivered to an HTTPS endpoint you control. Every payload is signed with HMAC-SHA256 using a per-endpoint secret so you can prove the request came from us. Use webhooks to update your own database when a send completes, slack a channel when a hard bounce happens, or trigger downstream automation when a new subscriber joins a list.
Events
| Event | When it fires |
|---|---|
message.sent | The receiving server accepted the message (250 OK) |
message.delivered | Receiver returned a successful DSN or Delivered-To |
message.bounced | Hard bounce (5xx) — recipient auto-suppressed |
message.opened | Tracking pixel was loaded |
message.clicked | A tracked link was clicked |
message.complained | Mailbox provider reported a spam complaint via FBL |
message.unsubscribed | Recipient hit the one-click unsubscribe link |
list.subscriber.added | Subscriber transitioned to active on a list |
list.subscriber.removed | Subscriber unsubscribed, hard-bounced, or was deleted |
When you create an endpoint without specifying events, we default to
the seven message.* events. Pass an explicit array to subscribe to
the list events too, or to scope the endpoint to a smaller set.
Add an endpoint
The fastest way is Settings → Webhooks → New endpoint in the dashboard. Pick the events, paste the HTTPS URL, copy the signing secret (shown once).
The secret looks like whsec_.... Store it as an env var on whichever
service runs your handler — you'll need it to verify signatures.
Request shape
Every delivery is a POST with three headers and a JSON body:
POST /koltrix HTTP/1.1
Host: hooks.acme.com
Content-Type: application/json
X-Koltrix-Event: message.delivered
X-Koltrix-Signature: sha256=8f30b3...
User-Agent: Koltrix-Webhook/1.0
{
"event": "message.delivered",
"timestamp": 1716220800,
"message_id": "01HX9C8...",
"to": "[email protected]",
"from": "[email protected]",
"subject": "Your invoice"
}| Header | Description |
|---|---|
X-Koltrix-Event | The event name (matches event in the body). |
X-Koltrix-Signature | sha256= + hex HMAC of the raw request body. |
User-Agent | Always Koltrix-Webhook/1.0. |
The body fields vary by event:
message.*events includemessage_id,to,from,subject, plus event-specific fields (urlonmessage.clicked,bounce_reasononmessage.bounced, etc.).list.subscriber.*events includelist_id,subscriber_id,email, and areason.
Verify the signature
The signing scheme is HMAC-SHA256 over the raw request body using
your endpoint's secret. The hex digest is prefixed with sha256= to
match the header.
// Node 18+ — verify in your handler before trusting the payload.
import crypto from "node:crypto";
function verifyKoltrix(rawBody: string | Buffer, signature: string, secret: string) {
const expected = "sha256=" + crypto
.createHmac("sha256", secret)
.update(rawBody)
.digest("hex");
// timingSafeEqual avoids leaking via timing differences.
const a = Buffer.from(expected);
const b = Buffer.from(signature);
return a.length === b.length && crypto.timingSafeEqual(a, b);
}# Python equivalent.
import hmac, hashlib
def verify_koltrix(body: bytes, signature: str, secret: str) -> bool:
expected = "sha256=" + hmac.new(
secret.encode(),
body,
hashlib.sha256,
).hexdigest()
return hmac.compare_digest(expected, signature)// Go equivalent.
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
)
func verifyKoltrix(body []byte, signature, secret string) bool {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write(body)
expected := "sha256=" + hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(signature))
}Use the raw bytes — not a re-serialised JSON object. If your framework parses JSON before your handler sees the body, you'll need a raw-body middleware (e.g.
express.raw({ type: "application/json" })) or the signature won't match.
Retries
When your endpoint responds with a non-2xx status — or doesn't respond within 8 seconds — we record the failed status code and response body and retry with exponential backoff:
| Attempt | Delay after previous | Cumulative time |
|---|---|---|
| 1 | (initial fire) | 0 |
| 2 | 1 minute | 1 min |
| 3 | 5 minutes | 6 min |
| 4 | 30 minutes | 36 min |
| 5 | 2 hours | 2h 36m |
| 6 | 12 hours | 14h 36m |
| 7 | 24 hours | 38h 36m |
After the 7th attempt the delivery is marked failed and stops
retrying.
We recommend your handler:
- Responds
200 OKthe moment the payload is verified and processes asynchronously. Long-running handlers will time out and get retried, which means duplicate work. - Tolerates duplicates. A retry can land before the first response
was written, so design your handler to be idempotent — usually by
storing
message_id(ormessage_id + event) in a dedupe table. - Verifies the signature before doing anything trust-sensitive. Bad signatures are dropped at our edge, but if a deployment leaks your endpoint URL you don't want an attacker firing fake events into your billing logic.
Inspecting deliveries
The dashboard shows every recent delivery under Settings → Webhooks → endpoint → Deliveries — status code, response body (first 2 KB), attempt count. Retries appear as additional rows so you can see the timeline.
Testing
Two options:
- Send test next to a webhook in the dashboard — fires a synthetic
message.sentevent with"_synthetic": truein the body so you can filter it out of analytics. - Replay — pick any past delivery and click Replay to refire the exact same payload with a fresh signature. Useful for debugging a handler that rejected the first attempt.
Disabling an endpoint
Toggle Active off in the dashboard. New deliveries stop, but the endpoint, secret, and delivery history are kept so you can re-enable it later without losing context.
Cross-links
- The auth model behind these endpoints — Authentication
- Plug webhooks into Slack, Zapier, your own service — Integrations
- Common verification failures — Troubleshooting