Skip to content

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}.

  • changes lists what changed, e.g. ["status"] or ["snoozedUntil"].
  • source says where a contact change came from, when we know it: widget, api, import or manual (created in the app).
  • updated is true when 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.

  1. Split the header and read t and v1.
  2. Reject the request if t is more than 5 minutes away from your clock. This stops replays.
  3. Compute the HMAC over the raw body bytes. Don't parse and re-serialize the JSON first.
  4. 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.