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
| Event | When it fires |
|---|---|
conversation.created | A new conversation appeared |
conversation.status_changed | A conversation was opened, closed, snoozed or marked spam |
conversation.assigned | A conversation was assigned to an operator |
conversation.tags_changed | A conversation's tags changed |
message.created | A new message — inbound, outbound or an internal note |
contact.created | A new contact appeared |
contact.updated | A contact changed |
organization.created | A 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:
| Header | Meaning |
|---|---|
X-Webhook-Id | Event id, identical to id in the body |
X-Webhook-Event | Event type |
X-Webhook-Timestamp | Send time, unix seconds |
X-Webhook-Signature | The signature, see below |
X-Webhook-Attempt | Attempt 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:
| Attempt | After |
|---|---|
| 2 | 1 minute |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |
| 6 | 12 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 →