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_ | An operator solves a conversation |
conversation.solved_ | Lyro considers a conversation finished |
conversation.solved_ | Automatic Solve resolves a conversation |
conversation.operator_ | 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_ | A customer satisfaction survey is submitted |
ticket.operator_replied, ticket.contact_replied and ticket.internal_ are deprecated. Tidio recommends ticket.replied, which covers all three.
Every delivery has the same envelope, with the event data in content:
{
"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
- In the Tidio panel, go to Settings > Developer > Webhooks.
- Add a webhook and paste your endpoint URL.
- Choose a stack, or create one for your first webhook.
- Select the events that should trigger it, and save. You can pause, edit or delete the webhook from its stack.
- 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.
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:
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 verifies the
x-tidio-signatureheader 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
2xxstraight 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 and 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_atbefore overwriting state. - Store each
webhook_idyou've processed and skip repeats.
How Hookdeck Can Help: Hookdeck's 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 sits in front of your receiver, checks every Tidio signature, and holds each event until your service has processed it.
Get started with Hookdeck for free and handle Tidio webhooks reliably in minutes.