Webhooks
For Developers

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.

POST/api/v1/webhooks

Register a webhook

body
{
  "url": "https://example.com/hooks/supporthub",
  "events": ["ticket.created", "message.created"],
  "description": "Sync to CRM"
}
201 Created
{
  "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"
}
secret is returned only in this response. Save it: you need it to verify signatures.

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.

GET/api/v1/webhooks

List 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": "..." }
  ]
}
PATCH/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}.

body
{
  "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.

DELETE/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.

POST/api/v1/webhooks/{webhook_id}/test

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

200 OK
{
  "delivered": true,
  "status_code": 204,
  "error": null,
  "latency_ms": 184,
  "delivery_id": "3f...",
  "signature_header": "sha256=5d41..."
}

Events

EventWhendata fields
ticket.createdA ticket was created (in any channel, the API included)ticket_id, status, priority, contact_id, subject
ticket.updatedThe ticket was edited in the dashboard or through PATCH /api/v1/tickets, assigned, or its status changed after a replyticket_id; the other fields depend on the source (after PATCH just workspace_id), so get the current state through REST
ticket.assignedThe ticket was assigned to an operator, including a reassignmentticket_id, operator_id, operator_name, operator_avatar_url, status
ticket.transferredThe ticket moved to another department (possibly straight to an operator)ticket_id, department_id, department_name, operator_id, operator_name
ticket.force_takenAn operator took over a ticket assigned to someone elseticket_id, operator_id, previous_operator_id
ticket.closedThe ticket was closedticket_id, closed_at, resolution_secs
ticket.reopenedThe ticket was reopenedticket_id, status
ticket.ratedA rating was leftticket_id, rating; comment when the rating came through the API
message.createdA new message in any channel, including internal notes (is_internal) and system messagesid, message_id, ticket_id, sender_type, sender_id, sender_name, content, is_internal, media, created_at
message.editedA message was editedid, message_id, ticket_id, content, is_internal, edited_at
message.reaction.addedA widget visitor or an operator reacted to a messagemessage_id, reactor_kind, reactor_id, emoji
message.reaction.removedA widget visitor removed a reactionmessage_id, reactor_id
contact.createdThe widget identified a visitor for the first time and created the contact while doing soid, internal_id, email, full_name, phone, telegram_id
contact.identifiedEvery identify call from the widgetid, internal_id, email, full_name, phone, telegram_id
visitor.reply_waitingAn 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.changedAn operator changed their status (online, away, dnd, custom, offline)user_id, status, status_text, status_emoji
billing.payment_receivedA top-up was credited to the project balancepayment_id, order_id, amount, currency, method, transaction_id, new_balance
billing.payment_failedThe payment gateway reported the payment as cancelledpayment_id, order_id, amount, method, reason
billing.plan_renewedThe plan was renewed from the balanceplan, amount, new_balance, expires_at, trigger (auto / manual)
billing.plan_changedThe plan ended and switched to the paid plan chosen as next (paid from the balance), or a trial turned into a paid planold_plan, new_plan, expires_at; plus amount and new_balance when the chosen plan is paid, reason for a trial
billing.plan_downgradedThe project moved to Starter: the balance was short, auto-renew was off with no next plan, or a trial ended unpaidold_plan, new_plan, expires_at; plus reason for a trial
billing.balance_lowA renewal attempt found the balance below the plan priceplan, balance, price, shortfall; plus trigger for a manual renewal
billing.subscription_expiredA paid plan ended without renewalold_plan, reason (insufficient_balance / auto_renew_disabled), expired_at
  • message.created includes operators’ internal notes too: filter them out by is_internal.
  • Messages sent with POST /api/v1/tickets/{id}/messages come with sender_type: bot and the project’s id in sender_id, so you can tell your own messages apart.
  • media[].url in 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 with GET /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

POST body
{
  "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.* and message.* events get ticket and messages (when the event has a ticket_id), contact.* events get contact and tickets (the contact’s tickets). Other events have no links.
Headers
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–6

Billing event examples

billing.payment_received
{
  "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": {}
}
billing.plan_renewed / billing.plan_changed (data)
{
  "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"
  }
}
billing.balance_low (data)
{
  "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.

server.py (Flask)
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 "", 204
server.mjs (Node.js / Express)
import 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 2xx response within 10 seconds. Redirects aren’t followed: a 3xx counts 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 failed and the subscription’s consecutive_failures goes 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 timestamp if you need to.
Was this page helpful?