# Guide to Tidio Webhooks: Features and Best Practices

Tidio webhooks tell your systems what happens in your customer service workspace: a contact is created or updated, an operator or Lyro, Tidio's AI agent, solves a conversation, a ticket is created, reassigned or answered, or a customer rates their experience. If you sync Tidio with a CRM, a data warehouse or your own support tooling, webhooks are how those changes reach you.

This guide covers how Tidio webhooks are configured, the events and envelope they use, how the signature header works with more than one secret, and how to build a receiver that keeps within Tidio's four-second timeout.

## What are Tidio webhooks?

Tidio sends each event as a JSON `POST` to the endpoints you add in the Tidio panel. Webhooks are available on the Plus and Premium plans, and only project owners and admins can manage them.

Every delivery is signed in the `x-tidio-signature` header, which holds a timestamp and one signature per active secret: `t=<unix seconds>,s=<hex>[,s=<hex>...]`. Each signature is a hex HMAC-SHA256 over the raw body, an underscore and the `t` value. The order matters: body first, timestamp last.

## Tidio webhook features

| Feature | Details |
| --- | --- |
| Plans | Plus and Premium |
| Configuration | Tidio panel, **Settings > Developer > Webhooks** (owners and admins) |
| Grouping | Endpoints belong to stacks |
| Signature header | `x-tidio-signature: t=<unix seconds>,s=<hex>[,s=<hex>...]` |
| Signed content | `<raw body>_<t>` |
| Secrets | One `s` value per active secret. Any match is valid |
| Event field | `topic` |
| Envelope | `created_at`, `project_public_key`, `topic`, `version`, `webhook_id`, `content` |
| Success response | Any `2xx` within 4 seconds |
| Retries | 9, over about 4 hours 45 minutes |
| Ordering | Not guaranteed. Use `created_at` |
| Duplicates | Possible. Deduplicate on `webhook_id` |
| API changes | Changes made through the Tidio OpenAPI don't trigger webhooks |

## Common events

| Topic | Fires when |
| --- | --- |
| `contact.created`, `contact.updated` | A contact is created or updated |
| `conversation.solved_by_operator` | An operator solves a conversation |
| `conversation.solved_by_lyro` | Lyro considers a conversation finished |
| `conversation.solved_automatically` | Automatic Solve resolves a conversation |
| `conversation.operator_replied` | An operator message is saved on the chat channel |
| `ticket.created`, `ticket.status_updated` | A ticket is created or its status changes |
| `ticket.operator_assigned`, `ticket.holder_changed` | A ticket gets a new operator or holder |
| `ticket.replied` | Any reply is added to a ticket, whoever sent it |
| `customer_satisfaction.rated` | A customer satisfaction survey is submitted |

`ticket.operator_replied`, `ticket.contact_replied` and `ticket.internal_note_added` are deprecated. Tidio recommends `ticket.replied`, which covers all three.

Every delivery has the same envelope, with the event data in `content`:

```json
{
  "created_at": 1687177445,
  "project_public_key": "your_project_public_key",
  "topic": "contact.created",
  "version": 1,
  "webhook_id": "7307ac4d-2c74-4d32-b7f9-f7d037ed2ebb",
  "content": {}
}

```

## Setting up Tidio webhooks

1. In the Tidio panel, go to **Settings > Developer > Webhooks**.
2. Add a webhook and paste your endpoint URL.
3. Choose a stack, or create one for your first webhook.
4. Select the events that should trigger it, and save. You can pause, edit or delete the webhook from its stack.
5. Copy the webhook secret and store it server-side, for example as `TIDIO_WEBHOOK_SECRET`.

Tidio's docs don't say exactly where the panel shows the secret, so look in the webhook's settings. To test, trigger a real event in the panel, such as creating a contact. Changes you make through the API won't send a webhook.

If you receive Tidio webhooks through Hookdeck, use the Hookdeck source URL as the endpoint URL, create a source with the Tidio type and paste the secret. The Tidio source accepts `POST` only, which is how Tidio delivers webhooks, and it accepts a delivery when any `s` value in the header matches.

## Securing Tidio webhooks

Parse `x-tidio-signature` into its `t` value and every `s` value. Build the message from the raw body bytes, an underscore and `t`. Compute a hex HMAC-SHA256 with your secret, and accept the request if any `s` matches in constant time.

```javascript
const crypto = require('crypto');
const express = require('express');

function verifyTidioSignature(rawBody, signatureHeader, secret, toleranceSeconds = 0) {
  if (!signatureHeader || !secret) return false;

  let timestamp;
  const signatures = [];
  for (const part of signatureHeader.split(',')) {
    const i = part.indexOf('=');
    if (i === -1) continue;
    const key = part.slice(0, i).trim();
    const value = part.slice(i + 1).trim();
    if (key === 't' && timestamp === undefined) timestamp = value;
    else if (key === 's') signatures.push(value);
  }
  if (!timestamp || signatures.length === 0) return false;

  // Optional replay window. Off by default (see below).
  if (toleranceSeconds > 0) {
    const t = Number(timestamp);
    if (!Number.isFinite(t)) return false;
    if (Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
  }

  // The raw body FIRST, then an underscore, then t.
  const message = Buffer.concat([rawBody, Buffer.from('_'), Buffer.from(timestamp, 'latin1')]);
  const expected = crypto.createHmac('sha256', secret).update(message).digest();

  return signatures.some((s) => {
    if (!/^[0-9a-fA-F]{64}$/.test(s)) return false;
    const received = Buffer.from(s, 'hex');
    return received.length === expected.length && crypto.timingSafeEqual(received, expected);
  });
}

const app = express();

app.post('/webhooks/tidio', express.raw({ type: '*/*' }), (req, res) => {
  const secret = process.env.TIDIO_WEBHOOK_SECRET;
  if (!secret) return res.sendStatus(500); // fail closed

  if (!verifyTidioSignature(req.body, req.get('x-tidio-signature'), secret)) {
    return res.sendStatus(401);
  }

  const event = JSON.parse(req.body.toString('utf8'));
  // Deduplicate on event.webhook_id, route on event.topic, then process asynchronously.
  res.sendStatus(200);
});

```

The same check in Python:

```python
import hashlib
import hmac
import re
import time

def verify_tidio_signature(raw_body: bytes, signature_header, secret: str, tolerance_seconds: int = 0) -> bool:
    if not signature_header or not secret:
        return False
    timestamp, signatures = None, []
    for part in signature_header.split(","):
        key, sep, value = part.partition("=")
        if not sep:
            continue
        key, value = key.strip(), value.strip()
        if key == "t" and timestamp is None:
            timestamp = value
        elif key == "s":
            signatures.append(value)
    if not timestamp or not signatures:
        return False
    # Optional replay window. Off by default (see below).
    if tolerance_seconds > 0:
        try:
            t = float(timestamp)
        except ValueError:
            return False
        if abs(time.time() - t) > tolerance_seconds:
            return False
    # The raw body FIRST, then an underscore, then t.
    message = raw_body + b"_" + timestamp.encode("latin-1")
    expected = hmac.new(secret.encode("utf-8"), message, hashlib.sha256).digest()
    return any(
        re.fullmatch(r"[0-9a-fA-F]{64}", s) and hmac.compare_digest(bytes.fromhex(s), expected)
        for s in signatures
    )

# In FastAPI: raw_body = await request.body()
# verify_tidio_signature(raw_body, request.headers.get("x-tidio-signature"),
#                        os.environ["TIDIO_WEBHOOK_SECRET"])

```

Tidio's own Node sample calls `crypto.timingSafeEqual` without checking lengths first, which throws on a malformed signature instead of returning `false`. The length check above avoids that.

Tidio's docs don't set a timestamp tolerance, and the samples leave the window off by default. Hookdeck applies no timestamp tolerance either, and its retries and replays resend Tidio's original header, so a window on your endpoint would reject late ones. If you want one, pass `toleranceSeconds` and rely on `webhook_id` for duplicates.

To check your verifier, use the secret `tidio-guide-secret` and this exact body:

```
{"created_at":1687177445,"topic":"contact.created","version":1,"webhook_id":"7307ac4d-2c74-4d32-b7f9-f7d037ed2ebb","content":{}}

```

The header should be:

```
x-tidio-signature: t=1687177446,s=f9a846fc6f3a4ea77839fb86bbd9997905150e7e44d12843fbd142ee4b27c4f2

```

> **Make Tidio webhooks production-ready.** [Hookdeck Event Gateway](/event-gateway) verifies the `x-tidio-signature` header on every delivery, answers inside Tidio's four-second timeout, and queues each contact, conversation and ticket event until your service has processed it.

## Tidio webhook limitations and pain points

### A four-second timeout

**The Problem:** Deliveries fail even though your endpoint processes them.

**Why It Happens:** Tidio waits four seconds for a `2xx`. Writing to a CRM or calling another API inline easily takes longer, and Tidio then counts the delivery as failed and retries it.

**Workarounds:**

* Verify, store and return `2xx` straight away, then process from a queue.
* Deduplicate on `webhook_id`, because a slow success followed by a retry delivers the event twice.

**How Hookdeck Can Help:** Hookdeck acknowledges each delivery as soon as it arrives, then delivers to your service with a [delivery timeout](/docs/destinations#set-a-delivery-timeout) and [retries](/docs/retries) you configure.

### Retries stop after about five hours

**The Problem:** Events sent during a longer outage are lost.

**Why It Happens:** Tidio retries nine times, at 5 seconds, 10 seconds, 30 seconds, 1 minute, 3 minutes, 10 minutes, 30 minutes, 1 hour and 3 hours. After about 4 hours 45 minutes it stops.

**Workarounds:**

* Review the webhook history in the Tidio panel after an incident.
* Re-read contacts and tickets from the Tidio API for the affected period.

**How Hookdeck Can Help:** Hookdeck stores every delivery it receives and keeps retrying to your service on your schedule, so a longer outage on your side doesn't lose events.

### No ordering, and duplicates

**The Problem:** A contact update overwrites a newer one, or a ticket is processed twice.

**Why It Happens:** Tidio doesn't guarantee delivery order and says duplicates are possible.

**Workarounds:**

* Compare `created_at` before overwriting state.
* Store each `webhook_id` you've processed and skip repeats.

**How Hookdeck Can Help:** Hookdeck's [deduplication](/docs/deduplication) can drop repeated `webhook_id` values before they reach your service.

### API changes don't send webhooks

**The Problem:** Your own system doesn't see a change that another integration made.

**Why It Happens:** Changes made through the Tidio OpenAPI don't trigger webhooks. Only changes made in Tidio itself do.

**Workarounds:**

* Update your own records when you write through the API, rather than waiting for a webhook.
* If several integrations write to Tidio, have them publish their own change events.

**How Hookdeck Can Help:** Hookdeck only sees what Tidio sends. If your other integrations publish their own change events, a second Hookdeck source can receive them and deliver to the same destination, so one handler sees both.

## Best practices

### Verify against the raw body

Tidio signs the exact body it sends. In Express, use `express.raw({ type: '*/*' })`. In Next.js, use `Buffer.from(await request.arrayBuffer())`. In FastAPI, use `await request.body()`.

### Keep the secret list short

Each active secret adds an `s` value to the header. Remove old secrets once every receiver uses the new one.

### Move to `ticket.replied`

New integrations should subscribe to `ticket.replied` instead of the three deprecated reply topics, and existing ones should plan the switch while the old topics still work.

### Route on `topic` and `version`

Read `topic` to choose a handler and check `version` so a future change to a topic's shape doesn't break your parser silently.

## Conclusion

Tidio's signature puts the body before the timestamp and carries one `s` value per secret, so parse the header carefully and accept any match. Answer within four seconds, deduplicate on `webhook_id`, and don't rely on order or on webhooks for changes made through the API.

[Hookdeck Event Gateway](https://hookdeck.com) sits in front of your receiver, checks every Tidio signature, and holds each event until your service has processed it.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle Tidio webhooks reliably in minutes.