Gareth Wilson Gareth Wilson

Guide to Tidio Webhooks: Features and Best Practices

Published


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

FeatureDetails
PlansPlus and Premium
ConfigurationTidio panel, Settings > Developer > Webhooks (owners and admins)
GroupingEndpoints belong to stacks
Signature headerx-tidio-signature: t=<unix seconds>,s=<hex>[,s=<hex>...]
Signed content<raw body>_<t>
SecretsOne s value per active secret. Any match is valid
Event fieldtopic
Envelopecreated_at, project_public_key, topic, version, webhook_id, content
Success responseAny 2xx within 4 seconds
Retries9, over about 4 hours 45 minutes
OrderingNot guaranteed. Use created_at
DuplicatesPossible. Deduplicate on webhook_id
API changesChanges made through the Tidio OpenAPI don't trigger webhooks

Common events

TopicFires when
contact.created, contact.updatedA contact is created or updated
conversation.solved_by_operatorAn operator solves a conversation
conversation.solved_by_lyroLyro considers a conversation finished
conversation.solved_automaticallyAutomatic Solve resolves a conversation
conversation.operator_repliedAn operator message is saved on the chat channel
ticket.created, ticket.status_updatedA ticket is created or its status changes
ticket.operator_assigned, ticket.holder_changedA ticket gets a new operator or holder
ticket.repliedAny reply is added to a ticket, whoever sent it
customer_satisfaction.ratedA 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:

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

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-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 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_at before overwriting state.
  • Store each webhook_id you'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.


Gareth Wilson

Gareth Wilson

Product Marketing

Multi-time founding marketer, Gareth is PMM at Hookdeck and author of the newsletter, Community Inc.