Webhooks

We send you a request when something happens in the project.

Why

Without webhooks an integration has to poll: «is there a new message yet?». That is slow, spends your rate limit, and still lags by the polling interval. A webhook arrives at once.

Subscribing

In the app: Settings → API & MCP → Webhooks → Add webhook. Give an address and tick the events.

Through the API (this is what connectors do):

curl -X POST "https://api.tg-desk.com/v1/webhooks" \
  -H "Authorization: Bearer $TYGY_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "CRM sync",
    "url": "https://example.com/tygy-webhook",
    "events": ["message.created", "conversation.status_changed"]
  }'

The answer carries the signing secret. It is shown once — save it.

The address must start with https://. The exception is http://localhost, so you can debug locally.

Events

EventWhen it fires
conversation.createdA new conversation appeared
conversation.status_changedA conversation was opened, closed, snoozed or marked spam
conversation.assignedA conversation was assigned to an operator
conversation.tags_changedA conversation's tags changed
message.createdA new message — inbound, outbound or an internal note
contact.createdA new contact appeared
contact.updatedA contact changed
organization.createdA new client organization appeared

An event fires no matter who made the change: an operator in the app, a customer in a channel, a scenario, or your own integration through the API.

What arrives

A POST with this body:

{
  "id": "evt_9f2a1c8e40b1f2a4b6d9d3e7",
  "type": "message.created",
  "created_at": "2026-09-12T10:00:00.000Z",
  "api_version": "v1",
  "company_id": "ckx1…",
  "data": { "id": "m1", "conversation_id": "c1", "body": "Hello", "…": "…" }
}

Inside data is the same object the matching read method would return. No second round trip for details.

Headers:

HeaderMeaning
X-Webhook-IdEvent id, identical to id in the body
X-Webhook-EventEvent type
X-Webhook-TimestampSend time, unix seconds
X-Webhook-SignatureThe signature, see below
X-Webhook-AttemptAttempt number, e.g. 1/6

Verifying the signature

The signature is an HMAC-SHA256 of «timestamp.body» with your signing secret. Verify it on every request: without that, anyone can send you a forged event.

Use the raw request body, before JSON parsing. JSON re-serialised from an object produces different bytes and the signature will not match.

const crypto = require('node:crypto');

function verify(rawBody, headers, secret) {
  const timestamp = headers['x-webhook-timestamp'];
  const signature = headers['x-webhook-signature'];
  // Refuse stale requests — this is the replay guard.
  if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 300) return false;

  const expected =
    'v1=' + crypto.createHmac('sha256', secret).update(`${timestamp}.${rawBody}`).digest('hex');
  const a = Buffer.from(expected);
  const b = Buffer.from(signature ?? '');
  return a.length === b.length && crypto.timingSafeEqual(a, b);
}
import hashlib, hmac, time

def verify(raw_body: bytes, headers: dict, secret: str) -> bool:
    timestamp = headers.get("x-webhook-timestamp", "")
    signature = headers.get("x-webhook-signature", "")
    if abs(time.time() - int(timestamp or 0)) > 300:
        return False
    expected = "v1=" + hmac.new(
        secret.encode(), f"{timestamp}.".encode() + raw_body, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(expected, signature)
function verify(string $rawBody, array $headers, string $secret): bool {
    $timestamp = $headers['x-webhook-timestamp'] ?? '';
    $signature = $headers['x-webhook-signature'] ?? '';
    if (abs(time() - (int) $timestamp) > 300) return false;
    $expected = 'v1=' . hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
    return hash_equals($expected, $signature);
}

What to answer

Answer with any 2xx as fast as you can — you have 5 seconds. If processing takes longer, put the event on your own queue and answer immediately.

Retries

If your address does not answer, or answers with something other than 2xx, we retry:

AttemptAfter
21 minute
35 minutes
430 minutes
52 hours
612 hours

Six attempts over roughly fifteen hours.

A 410 Gone stops the subscription at once — that is how you tell us the address is retired.

If ten events in a row exhaust every attempt, we pause the subscription and notify the project's owner and admins. A webhook that quietly died is worse than one that says so. Once the address is fixed, resume the subscription in the app and the counter resets.

The log

In the app every subscription has a 7-day delivery log: response code, duration, error text, attempt number. The same is available through GET /v1/webhooks/{id}/deliveries.

The «Test» button sends a webhook.test event — a quick way to check the address accepts requests at all.

Order and duplicates

Order is not guaranteed: with retries, a later event can arrive first. Use created_at inside the body.

The same event can arrive twice — for instance if your answer never reached us. Remember the event id and skip one you have already handled.

Previous: Retrying writes

Next: Ready-made connectors