Webhooks
Webhooks are the push model: SupportHub sends a POST request to your URL when something happens. If you have no public address, use long polling.
/api/v1/webhooksRegister a webhook
{
"url": "https://example.com/hooks/supporthub",
"events": ["ticket.created", "message.created"],
"description": "Sync to CRM"
}{
"id": "9c...",
"url": "https://example.com/hooks/supporthub",
"events": ["ticket.created", "message.created"],
"secret": "Vd9q...long-random...",
"is_active": true,
"description": "Sync to CRM",
"created_at": "2026-04-06T10:00:00"
}An empty or missing events subscribes to every event. An unknown event name (a typo, or an event that doesn’t exist) gives 422 VALIDATION_ERROR: detail.unknown lists those names and detail.valid every event you can subscribe to. PATCH checks the same. The URL isn’t checked on registration: the test delivery (below) checks that the address is reachable.
/api/v1/webhooksList webhooks
Returns the project’s subscriptions, without the secret and without the failure counter:
{
"items": [
{ "id": "9c...", "url": "...", "events": [...], "is_active": true, "description": "...", "created_at": "..." }
]
}/api/v1/webhooks/{webhook_id}Update a webhook
A partial update: send only the fields you need. The main use is turning a webhook back on after it was switched off automatically: {"is_active": true}.
{
"url": "https://example.com/hooks/v2",
"events": ["ticket.created"],
"description": "Updated subscription",
"is_active": true
}is_active: true also resets consecutive_failures to 0. The response is the subscription, including consecutive_failures. An unknown id gives 404.
/api/v1/webhooks/{webhook_id}Delete a webhook
The response is 204 with no body. The subscription is deleted with its delivery history; scheduled retries are no longer sent.
/api/v1/webhooks/{webhook_id}/testSend a test event
Immediately sends a webhook.test event to the URL with the same signature, headers and envelope as real events, and returns the result. One attempt, no retries; it works for a disabled webhook too.
{
"delivered": true,
"status_code": 204,
"error": null,
"latency_ms": 184,
"delivery_id": "3f...",
"signature_header": "sha256=5d41..."
}Events
| Event | When | data fields |
|---|---|---|
| ticket.created | A ticket was created (in any channel, the API included) | ticket_id, status, priority, contact_id, subject |
| ticket.updated | The ticket was edited in the dashboard or through PATCH /api/v1/tickets, assigned, or its status changed after a reply | ticket_id; the other fields depend on the source (after PATCH just workspace_id), so get the current state through REST |
| ticket.assigned | The ticket was assigned to an operator, including a reassignment | ticket_id, operator_id, operator_name, operator_avatar_url, status |
| ticket.transferred | The ticket moved to another department (possibly straight to an operator) | ticket_id, department_id, department_name, operator_id, operator_name |
| ticket.force_taken | An operator took over a ticket assigned to someone else | ticket_id, operator_id, previous_operator_id |
| ticket.closed | The ticket was closed | ticket_id, closed_at, resolution_secs |
| ticket.reopened | The ticket was reopened | ticket_id, status |
| ticket.rated | A rating was left | ticket_id, rating; comment when the rating came through the API |
| message.created | A new message in any channel, including internal notes (is_internal) and system messages | id, message_id, ticket_id, sender_type, sender_id, sender_name, content, is_internal, media, created_at |
| message.edited | A message was edited | id, message_id, ticket_id, content, is_internal, edited_at |
| message.reaction.added | A widget visitor or an operator reacted to a message | message_id, reactor_kind, reactor_id, emoji |
| message.reaction.removed | A widget visitor removed a reaction | message_id, reactor_id |
| contact.created | The widget identified a visitor for the first time and created the contact while doing so | id, internal_id, email, full_name, phone, telegram_id |
| contact.identified | Every identify call from the widget | id, internal_id, email, full_name, phone, telegram_id |
| visitor.reply_waiting | An operator replied in the widget while the visitor isn't in the chat (“Visitor notifications” on in the widget builder; at most once in 5 minutes per visitor) | contact_id, external_id, telegram_user_id, ticket_id, ticket_short_id, message_id, operator_name, preview, locale, surface, open_url, replied_at |
| operator.status.changed | An operator changed their status (online, away, dnd, custom, offline) | user_id, status, status_text, status_emoji |
| billing.payment_received | A top-up was credited to the project balance | payment_id, order_id, amount, currency, method, transaction_id, new_balance |
| billing.payment_failed | The payment gateway reported the payment as cancelled | payment_id, order_id, amount, method, reason |
| billing.plan_renewed | The plan was renewed from the balance | plan, amount, new_balance, expires_at, trigger (auto / manual) |
| billing.plan_changed | The plan ended and switched to the paid plan chosen as next (paid from the balance), or a trial turned into a paid plan | old_plan, new_plan, expires_at; plus amount and new_balance when the chosen plan is paid, reason for a trial |
| billing.plan_downgraded | The project moved to Starter: the balance was short, auto-renew was off with no next plan, or a trial ended unpaid | old_plan, new_plan, expires_at; plus reason for a trial |
| billing.balance_low | A renewal attempt found the balance below the plan price | plan, balance, price, shortfall; plus trigger for a manual renewal |
| billing.subscription_expired | A paid plan ended without renewal | old_plan, reason (insufficient_balance / auto_renew_disabled), expired_at |
message.createdincludes operators’ internal notes too: filter them out byis_internal.- Messages sent with
POST /api/v1/tickets/{id}/messagescome withsender_type: botand the project’s id insender_id, so you can tell your own messages apart. media[].urlin events is the chat’s internal address (/api/media/…; for an internal note’s files, a signed link valid for 12 hours). Through the REST API, download files withGET /api/v1/media/{id}.- Only the widget sends
contact.*: contacts from Telegram, email, the API and other channels don’t trigger these events.
Request format
{
"id": "8c4f1a0b6d2e4f3a9b1c7d5e3f2a1b0c",
"type": "ticket.created",
"timestamp": "2026-05-07T10:00:00.123456+00:00",
"workspace_id": "27744f73-...",
"data": { "ticket_id": "aa...", "status": "new", "priority": "normal", "contact_id": "44e1...", "subject": "..." },
"_links": {
"ticket": "/api/v1/tickets/aa...",
"messages": "/api/v1/tickets/aa.../messages"
}
}id: the event’s id (32 hex characters). The same in every retry and every subscription, so it’s handy for dropping duplicates.data: the event’s short data (fields are in the table above). Get the full object through REST._links: relative addresses for GET requests with your API key, only to endpoints that exist:ticket.*andmessage.*events getticketandmessages(when the event has aticket_id),contact.*events getcontactandtickets(the contact’s tickets). Other events have no links.
Content-Type: application/json
X-Webhook-Signature: sha256=<hex digest>
X-Webhook-Event: ticket.created
X-Webhook-Id: 3f... # delivery id: one per subscription, the same in every retry
X-Webhook-Attempt: 1 # attempt number, 1–6Billing event examples
{
"id": "8c4f1a...",
"type": "billing.payment_received",
"timestamp": "2026-05-07T10:00:00.123456+00:00",
"workspace_id": "27744f73-...",
"data": {
"payment_id": "p1...",
"order_id": "ord_42",
"amount": 99.0,
"currency": "USD",
"method": "heleket",
"transaction_id": "txid_...",
"new_balance": 199.0
},
"_links": {}
}{
"type": "billing.plan_renewed",
"data": {
"plan": "pro",
"amount": 99.0,
"new_balance": 100.0,
"expires_at": "2026-06-07T10:00:00.123456",
"trigger": "auto"
}
}
{
"type": "billing.plan_changed",
"data": {
"old_plan": "pro",
"new_plan": "team",
"expires_at": "2026-06-07T10:00:00.123456"
}
}{
"type": "billing.balance_low",
"data": {
"plan": "pro",
"balance": 12.5,
"price": 99.0,
"shortfall": 86.5
}
}Verifying the signature
The signature is an HMAC-SHA256 of the raw request body keyed with the webhook secret, in hex, prefixed with sha256=. Compute it over the body bytes before parsing the JSON: re-serialised JSON won’t match.
import hmac, hashlib
from flask import Flask, request, abort
SECRET = b"Vd9q...long-random..."
app = Flask(__name__)
@app.post("/hooks/supporthub")
def hook():
sig = request.headers.get("X-Webhook-Signature", "")
expected = "sha256=" + hmac.new(
SECRET, request.get_data(), hashlib.sha256
).hexdigest()
if not hmac.compare_digest(sig, expected):
abort(401)
event = request.headers["X-Webhook-Event"]
payload = request.get_json()
print(event, payload)
return "", 204import express from "express";
import crypto from "node:crypto";
const SECRET = "Vd9q...long-random...";
const app = express();
app.post(
"/hooks/supporthub",
express.raw({ type: "application/json" }),
(req, res) => {
const expected =
"sha256=" +
crypto.createHmac("sha256", SECRET).update(req.body).digest("hex");
const sig = req.header("X-Webhook-Signature") || "";
const ok =
sig.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
if (!ok) return res.sendStatus(401);
const event = req.header("X-Webhook-Event");
const payload = JSON.parse(req.body.toString());
console.log(event, payload);
res.sendStatus(204);
}
);
app.listen(3000);Delivery and retries
- Success is a
2xxresponse within 10 seconds. Redirects aren’t followed: a3xxcounts as a failure. - On failure (another status, a timeout, a network error) there are up to 6 attempts: immediately, then 30 seconds, 2 minutes, 10 minutes, 1 hour and 6 hours after the previous one.
- If all 6 fail, the delivery is marked
failedand the subscription’sconsecutive_failuresgoes up by 1. After 5 such deliveries in a row the webhook is switched off (is_active: false); turn it back on with PATCH. Any successful delivery resets the counter. - Scheduled retries aren’t sent to a disabled or deleted webhook.
- Every delivery goes on its own and the order isn’t guaranteed: sort by
timestampif you need to.

