Webhooks
Get a signed HTTPS request when a conversation, message, contact or rating changes. Team and Business.
Webhooks send an HTTPS POST to your server when something happens in your workspace. Set them up under Settings → Workspace → API & webhooks → Add webhook: an endpoint URL, the events you want, and an optional description.
Team includes 5 webhooks, Business 25. Only owners and admins can manage them.
Events
| Event | Sent when | data |
|---|---|---|
conversation.created |
A conversation starts, e.g. in the widget, on Telegram, from the inbox or from a campaign | conversation, plus campaignId when a campaign started it |
conversation.updated |
Status (except resolving), assignment, labels or snooze change | conversation, changes |
conversation.closed |
A conversation is resolved | conversation, changes |
message.created |
A visitor, teammate or Trix writes. Internal notes are never sent. | conversation, message |
contact.created |
A contact is saved for the first time | contact, source |
contact.updated |
A contact's data changes | contact, source |
rating.created |
A visitor rates a conversation | rating, updated |
The objects in data have the same fields as in the REST API: conversation like GET /conversations/{id} (without messages), message like the messages endpoint, and contact like GET /contacts/{id}.
changeslists what changed, e.g.["status"]or["snoozedUntil"].sourcesays where a contact change came from, when we know it:widget,api,importormanual(created in the app).updatedistruewhen the visitor changed an earlier rating.
Send test delivers a webhook.test event. You can't subscribe to it.
Request
POST /your/endpoint HTTP/1.1
Content-Type: application/json
User-Agent: asktrix-webhooks/1
X-Asktrix-Event: message.created
X-Asktrix-Delivery: cm7q2…
X-Asktrix-Signature: t=1762337523,v1=5257a869e7…
{
"id": "cm7q2…",
"event": "message.created",
"createdAt": "2026-11-05T10:12:03.000Z",
"workspaceId": "cm1w0…",
"data": {
"conversation": { "id": "cm4p1…", "status": "unresolved", "mailbox": "main", "labels": [], "contact": { "id": "cm2k7…", "name": "Lena Fischer", "email": "[email protected]", "country": "DE" } },
"message": { "id": "cm5d3…", "conversationId": "cm4p1…", "from": "visitor", "type": "text", "content": "Hi!", "internal": false, "createdAt": "2026-11-05T10:12:03.000Z" }
}
}
The example is shortened. Real payloads carry every field.
Answer with any 2xx status within 10 seconds. Do the actual work afterwards, in a queue or background job. Redirects aren't followed and count as a failure.
Verify the signature
Every webhook has its own signing secret (whsec_…). Find it under the webhook's Signing secret → Reveal. X-Asktrix-Signature is t=<unix seconds>,v1=<hex>, where v1 is the HMAC-SHA256 of <t>.<raw body> with that secret.
- Split the header and read
tandv1. - Reject the request if
tis more than 5 minutes away from your clock. This stops replays. - Compute the HMAC over the raw body bytes. Don't parse and re-serialize the JSON first.
- Compare in constant time.
Node.js (Express)
import express from "express";
import { createHmac, timingSafeEqual } from "node:crypto";
const app = express();
function verify(rawBody, header, secret) {
const parts = Object.fromEntries((header ?? "").split(",").map((p) => p.trim().split("=")));
const t = Number(parts.t);
if (!t || Math.abs(Date.now() / 1000 - t) > 300) return false;
const expected = Buffer.from(createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex"));
const given = Buffer.from(parts.v1 ?? "");
return given.length === expected.length && timingSafeEqual(given, expected);
}
app.post("/webhooks/asktrix", express.raw({ type: "application/json" }), (req, res) => {
const raw = req.body.toString("utf8");
if (!verify(raw, req.get("X-Asktrix-Signature"), process.env.ASKTRIX_WEBHOOK_SECRET)) return res.status(400).end();
const event = JSON.parse(raw);
res.status(200).end();
// handle event.event / event.data here; event.id is stable across retries
});
Python (Flask)
import hashlib, hmac, os, time
from flask import Flask, request, abort
app = Flask(__name__)
SECRET = os.environ["ASKTRIX_WEBHOOK_SECRET"].encode()
def verify(raw: bytes, header: str) -> bool:
parts = dict(p.strip().split("=", 1) for p in header.split(",") if "=" in p)
try:
t = int(parts["t"])
except (KeyError, ValueError):
return False
if abs(time.time() - t) > 300:
return False
expected = hmac.new(SECRET, f"{t}.".encode() + raw, hashlib.sha256).hexdigest()
return hmac.compare_digest(expected, parts.get("v1", ""))
@app.post("/webhooks/asktrix")
def asktrix():
if not verify(request.get_data(), request.headers.get("X-Asktrix-Signature", "")):
abort(400)
event = request.get_json()
return "", 200
PHP
$raw = file_get_contents('php://input');
$header = $_SERVER['HTTP_X_ASKTRIX_SIGNATURE'] ?? '';
parse_str(str_replace(',', '&', $header), $parts);
$t = (int)($parts['t'] ?? 0);
$expected = hash_hmac('sha256', $t . '.' . $raw, getenv('ASKTRIX_WEBHOOK_SECRET'));
if (!$t || abs(time() - $t) > 300 || !hash_equals($expected, $parts['v1'] ?? '')) {
http_response_code(400);
exit;
}
$event = json_decode($raw, true);
http_response_code(200);
Rotate creates a new secret, and the old one stops working at once. Deploy the new secret right after rotating, or deliveries fail until you do. Failed deliveries are retried, so nothing is lost if you're quick.
Retries and duplicates
A delivery fails on a timeout, a connection error, a redirect or any status outside 2xx. We retry after 1 minute, 5 minutes, 30 minutes, 2 hours and 6 hours: six attempts over about 8.5 hours.
Retries keep the same id (also in X-Asktrix-Delivery). Store the IDs you've processed and skip repeats. Events can arrive out of order, so use createdAt or re-fetch the object from the REST API when order matters.
If an endpoint keeps failing (20 failed attempts in a row, and a delivery has used up all its retries), we switch the webhook off and notify the workspace's owners and admins. Fix the endpoint and switch Webhook active back on.
Delivery log
Open a webhook and go to Deliveries. You see the last 30 days: status, attempt number, duration, the exact body we sent and the first 2 KB of your server's response. Resend delivers one event again, for example after you fixed a bug in your handler.
Requirements
- The URL must be
https://and reachable from the internet. Private, local and internal addresses are blocked. - Webhooks only fire while the workspace's plan includes them. After a downgrade, your endpoints are kept but receive nothing.