1. Docs
  2. Developers

Webhooks

Get an HTTP request on your server the moment something happens in TalkingDot: a conversation starts, a message arrives, a chat is closed or rated, or a contact changes. Every request is signed so you can check it's genuine.

Set up an endpoint

  1. Build a URL that accepts POST requests. It must be reachable from the internet; see Endpoint addresses. Use https://.
  2. Add it in Settings → Webhooks. Enter the URL and choose the events it should receive.
  3. Copy the signing secret. Each endpoint has its own secret. Store it on your server, for example as the TALKINGDOT_WEBHOOK_SECRET environment variable.
  4. Verify every request. Check the signature as shown in Verify signatures, then reply with a 2xx status within 8 seconds.

Events

Choose any of these events for each endpoint. The data column lists what the request's data object contains.

EventSent whendata contains
conversation.createdA conversation startsconversation
message.createdA message is sent (by a visitor, teammate or bot)message, conversation
conversation.assignedA conversation is assignedconversation, assigned_by
conversation.closedA conversation is closedconversation, closed_by
conversation.reopenedA conversation is reopenedconversation
conversation.ratedA visitor rates a conversationconversation, rating, comment
conversation.taggedTags are added to a conversationconversation, tags_added
contact.createdA lead or user is created (gives an email or is identified)contact
contact.updatedContact details changecontact
contact.deletedA contact is deletedcontact (only id, external_id and email)
contact.lead_capturedA contact gives an email for the first time (chat, pre-chat form, popup, chatbot, API…)contact, source (e.g. “TalkingDot: pre-chat form”)
popup.submittedA visitor answers a popup (email signup or survey)popup (id, name, type, goal), response, contact
  • conversation, message and contact are the same objects the REST API returns.
  • assigned_by and closed_by are the ID of the teammate who made the change, or null when no teammate did (for example, an automation).
  • rating is the score from 1 to 5 and comment is the visitor's comment, which can be empty. tags_added is an array of the IDs of the tags that were just added.
  • Internal notes don't send message.created; only messages do.
  • response (popups) has email, name, phone and consent for signups, or score (NPS 0–10, ratings 1–5, thumbs 1 = 👍 / 0 = 👎), survey and comment for surveys, plus page_url, conversation_id and created_at.

The request

Each event is sent as a POST request with a JSON body and these headers:

HeaderDescription
Content-Typeapplication/json
X-TalkingDot-EventThe event name, for example message.created.
X-TalkingDot-DeliveryThe ID of this delivery. It stays the same when the delivery is retried.
X-TalkingDot-Signaturet=<unix timestamp>,v1=<signature>, where the signature is the hex HMAC-SHA256 of <t>.<raw body> keyed with the endpoint's signing secret. See Verify signatures.
HTTP
POST /webhooks/talkingdot HTTP/1.1
Host: www.example.com
Content-Type: application/json
X-TalkingDot-Event: message.created
X-TalkingDot-Delivery: 70412
X-TalkingDot-Signature: t=1791018900,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd

Every body has the same envelope:

FieldTypeDescription
idstringUnique ID of the event, starting with evt_. It stays the same when the delivery is retried, so use it to skip duplicates.
eventstringThe event name.
workspace_idintegerThe workspace the event happened in.
created_attimestampWhen the event happened (ISO 8601, UTC).
dataobjectThe objects for this event; see the events table.

Example: message.created

An example body for a visitor's message in the messenger (formatted for reading; the real body is compact JSON):

JSON
{
  "id": "evt_4f1c9a27d03b5e8a61c2",
  "event": "message.created",
  "workspace_id": 12,
  "created_at": "2026-10-03T09:15:00Z",
  "data": {
    "message": {
      "id": 88213,
      "conversation_id": 5531,
      "type": "message",
      "author_type": "contact",
      "author": { "id": 1842 },
      "body": "Thanks! I've just paid it.",
      "attachments": [],
      "via": "widget",
      "created_at": "2026-10-03T09:15:00Z"
    },
    "conversation": {
      "id": 5531,
      "number": 318,
      "contact_id": 1842,
      "status": "open",
      "priority": false,
      "assignee_id": 7,
      "team_id": 2,
      "channel": "chat",
      "started_by": "contact",
      "subject": null,
      "source_url": "https://www.example.com/pricing",
      "tags": [
        { "id": 5, "name": "billing" }
      ],
      "rating": null,
      "first_response_seconds": 94,
      "snoozed_until": null,
      "waiting_since": "2026-10-03T09:15:00Z",
      "last_message_at": "2026-10-03T09:15:00Z",
      "created_at": "2026-10-03T09:01:30Z",
      "closed_at": null,
      "url": "https://talkingdot.com/app/12/inbox/5531"
    }
  }
}

Verify signatures

Anyone can send a request to your endpoint, so check the X-TalkingDot-Signature header before you trust a request:

  1. Read the raw request body, byte for byte, before any JSON parsing. A body that has been parsed and re-encoded won't match.
  2. Split the header on , to get t (a Unix timestamp in seconds) and v1 (the signature).
  3. Compute the HMAC-SHA256 of the string t + "." + raw body, keyed with the endpoint's signing secret, as lowercase hex.
  4. Compare it with v1 using a constant-time comparison. Reject the request if they differ.
  5. We recommend also rejecting requests whose t is more than 5 minutes from your server's clock, so a captured request can't be replayed later. Every attempt, including retries, is signed with a fresh timestamp, so genuine retries pass this check.

PHP

PHP
<?php
// webhook.php
$secret    = (string) getenv('TALKINGDOT_WEBHOOK_SECRET');
$payload   = file_get_contents('php://input');            // raw body
$signature = $_SERVER['HTTP_X_TALKINGDOT_SIGNATURE'] ?? '';

$parts = [];
foreach (explode(',', $signature) as $pair) {
    [$key, $value] = array_pad(explode('=', trim($pair), 2), 2, '');
    $parts[$key] = $value;
}
$t  = $parts['t'] ?? '';
$v1 = $parts['v1'] ?? '';

$expected = hash_hmac('sha256', $t . '.' . $payload, $secret);

if (!ctype_digit($t) || abs(time() - (int) $t) > 300 || !hash_equals($expected, $v1)) {
    http_response_code(400);
    exit('Invalid signature');
}

$event = json_decode($payload, true);

// Skip events you've already handled (see "Handle duplicates"), then queue the work.
error_log('Received ' . $event['event'] . ' ' . $event['id']);

http_response_code(200);

Node.js (Express)

Use express.raw() on the webhook route so req.body is the raw bytes, not parsed JSON.

JavaScript
const crypto = require('crypto');
const express = require('express');

const app = express();
const SECRET = process.env.TALKINGDOT_WEBHOOK_SECRET;

function isValidSignature(rawBody, header) {
  const parts = {};
  for (const pair of String(header || '').split(',')) {
    const [key, ...rest] = pair.trim().split('=');
    parts[key] = rest.join('=');
  }
  const t = parts.t || '';
  const v1 = parts.v1 || '';

  if (!/^\d+$/.test(t) || Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
  if (!/^[0-9a-f]{64}$/.test(v1)) return false;

  const expected = crypto
    .createHmac('sha256', SECRET)
    .update(`${t}.`)
    .update(rawBody)
    .digest();

  return crypto.timingSafeEqual(expected, Buffer.from(v1, 'hex'));
}

app.post('/webhooks/talkingdot', express.raw({ type: 'application/json' }), (req, res) => {
  if (!Buffer.isBuffer(req.body) || !isValidSignature(req.body, req.get('X-TalkingDot-Signature'))) {
    return res.status(400).send('Invalid signature');
  }

  const event = JSON.parse(req.body.toString('utf8'));
  res.sendStatus(200); // acknowledge quickly...

  // ...then do the work. Skip event.id values you've already handled.
  console.log('Received', event.event, event.id);
});

app.listen(3000);

Python (Flask)

Python
import hashlib
import hmac
import os
import time

from flask import Flask, abort, request

app = Flask(__name__)
SECRET = os.environ["TALKINGDOT_WEBHOOK_SECRET"].encode()


def is_valid_signature(raw_body: bytes, header: str) -> bool:
    parts = dict(p.strip().split("=", 1) for p in header.split(",") if "=" in p)
    t = parts.get("t", "")
    v1 = parts.get("v1", "")

    if not t.isdigit() or abs(time.time() - int(t)) > 300:
        return False

    expected = hmac.new(SECRET, t.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected.encode(), v1.encode())


@app.post("/webhooks/talkingdot")
def talkingdot_webhook():
    raw_body = request.get_data()  # raw bytes; read before parsing JSON
    if not is_valid_signature(raw_body, request.headers.get("X-TalkingDot-Signature", "")):
        abort(400)

    event = request.get_json()
    # Skip event["id"] values you've already handled, then queue the work.
    app.logger.info("Received %s %s", event["event"], event["id"])
    return "", 200

Ruby (Sinatra)

Ruby
require "json"
require "openssl"
require "rack/utils"
require "sinatra"

SECRET = ENV.fetch("TALKINGDOT_WEBHOOK_SECRET")

def valid_signature?(raw_body, header)
  parts = header.to_s.split(",").map { |pair| pair.strip.split("=", 2) }.select { |pair| pair.length == 2 }.to_h
  t = parts["t"].to_s
  v1 = parts["v1"].to_s

  return false unless t.match?(/\A\d+\z/) && (Time.now.to_i - t.to_i).abs <= 300

  expected = OpenSSL::HMAC.hexdigest("SHA256", SECRET, "#{t}.#{raw_body}")
  Rack::Utils.secure_compare(expected, v1)
end

post "/webhooks/talkingdot" do
  request.body.rewind
  raw_body = request.body.read # raw bytes, before parsing JSON

  halt 400, "Invalid signature" unless valid_signature?(raw_body, request.env["HTTP_X_TALKINGDOT_SIGNATURE"])

  event = JSON.parse(raw_body)
  # Skip event["id"] values you've already handled, then queue the work.
  logger.info("Received #{event["event"]} #{event["id"]}")
  status 200
end

Keep the signing secret on your server. Anyone who has it can forge requests that pass verification. Never commit it to a repository or send it to a browser.

Responses and retries

Reply with any 2xx status within 8 seconds to acknowledge a delivery. Anything else counts as a failed attempt: another status code, a timeout, or a connection error. Redirects aren't followed, so a 3xx fails too; register the final URL. What you put in the response body doesn't matter.

Acknowledge first and do slow work (calling other APIs, sending emails) afterwards, for example in a background job, so you stay well inside the 8 seconds.

Retry schedule

A failed delivery is retried six more times, each wait counted from the previous attempt:

AttemptWhen
1Right after the event
21 minute after attempt 1 fails
35 minutes later
430 minutes later
52 hours later
66 hours later
712 hours later

If the last retry fails too, that delivery is dropped. If the data matters, catch up with the REST API, for example by listing conversations.

Failing endpoints are switched off

After 50 failed attempts in a row, the endpoint is disabled automatically and stops receiving events; deliveries still waiting for it are dropped. A successful delivery resets the count. Fix the endpoint, then turn it back on in Settings → Webhooks.

Endpoint addresses

Endpoints must be public http:// or https:// URLs; always use https:// in production. Private and internal addresses are refused, including localhost, 127.0.0.1 and private IP ranges such as 10.0.0.0/8 and 192.168.0.0/16, as are host names that resolve to them. To receive webhooks on your own computer, use a tunnel (see Test your endpoint).

Handle duplicates and ordering

Retries mean the same event can reach you more than once, for example when your server handled a request but timed out before replying. Make your handler idempotent:

  • Save the id of each event you process (it starts with evt_), with a unique index. If an id is already there, reply 200 and do nothing else.
  • Don't rely on events arriving in order. A retried delivery can arrive after a newer event. Compare the created_at of the event with what you've stored, or fetch the current state from the REST API when order matters.
PHP
<?php
// After verifying the signature (see above). $pdo is your PDO connection.
// CREATE TABLE processed_webhooks (event_id VARCHAR(64) PRIMARY KEY, received_at DATETIME NOT NULL);
$event = json_decode($payload, true);

$insert = $pdo->prepare('INSERT IGNORE INTO processed_webhooks (event_id, received_at) VALUES (?, UTC_TIMESTAMP())');
$insert->execute([$event['id']]);

if ($insert->rowCount() === 0) {
    http_response_code(200); // already handled
    exit;
}

// First time we've seen this event: handle it.

Test your endpoint

  • See the raw requests. Point a temporary endpoint at a request inspector (a RequestBin-style service) to see exactly what arrives: headers, signature and body.
  • Receive webhooks on your computer. Since localhost is refused, expose your local server with a tunnelling tool (for example ngrok or Cloudflare Tunnel) and register the public HTTPS URL it gives you.
  • Trigger real events. Open your website, send a message from the messenger (conversation.created and message.created), reply and close it in the Inbox (conversation.closed), then rate it (conversation.rated).
  • Send a signed test request yourself. This script signs a body with your secret the same way TalkingDot does, so you can test your verification code without waiting for an event:
Shell
SECRET='your-endpoint-signing-secret'
BODY='{"id":"evt_00000000000000000001","event":"message.created","workspace_id":12,"created_at":"2026-10-03T09:15:00Z","data":{}}'
T=$(date +%s)
SIG=$(printf '%s' "$T.$BODY" | openssl dgst -sha256 -hmac "$SECRET" | sed 's/^.*= //')

curl -X POST http://localhost:3000/webhooks/talkingdot \
  -H "Content-Type: application/json" \
  -H "X-TalkingDot-Event: message.created" \
  -H "X-TalkingDot-Delivery: 1" \
  -H "X-TalkingDot-Signature: t=$T,v1=$SIG" \
  --data-binary "$BODY"

Your handler should accept this request, and reject it with a 400 if you change one character of BODY after signing or use the wrong secret.

Last updated October 4, 2026 Something unclear? Tell us