- Docs
- 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
- Build a URL that accepts
POSTrequests. It must be reachable from the internet; see Endpoint addresses. Usehttps://. - Add it in Settings → Webhooks. Enter the URL and choose the events it should receive.
- Copy the signing secret. Each endpoint has its own secret. Store it on your server, for example as the
TALKINGDOT_WEBHOOK_SECRETenvironment variable. - Verify every request. Check the signature as shown in Verify signatures, then reply with a
2xxstatus within 8 seconds.
Events
Choose any of these events for each endpoint. The data column lists what the request's data object contains.
| Event | Sent when | data contains |
|---|---|---|
conversation.created | A conversation starts | conversation |
message.created | A message is sent (by a visitor, teammate or bot) | message, conversation |
conversation.assigned | A conversation is assigned | conversation, assigned_by |
conversation.closed | A conversation is closed | conversation, closed_by |
conversation.reopened | A conversation is reopened | conversation |
conversation.rated | A visitor rates a conversation | conversation, rating, comment |
conversation.tagged | Tags are added to a conversation | conversation, tags_added |
contact.created | A lead or user is created (gives an email or is identified) | contact |
contact.updated | Contact details change | contact |
contact.deleted | A contact is deleted | contact (only id, external_id and email) |
contact.lead_captured | A contact gives an email for the first time (chat, pre-chat form, popup, chatbot, API…) | contact, source (e.g. “TalkingDot: pre-chat form”) |
popup.submitted | A visitor answers a popup (email signup or survey) | popup (id, name, type, goal), response, contact |
conversation,messageandcontactare the same objects the REST API returns.assigned_byandclosed_byare the ID of the teammate who made the change, ornullwhen no teammate did (for example, an automation).ratingis the score from 1 to 5 andcommentis the visitor's comment, which can be empty.tags_addedis an array of the IDs of the tags that were just added.- Internal notes don't send
message.created; only messages do. response(popups) hasemail,name,phoneandconsentfor signups, orscore(NPS 0–10, ratings 1–5, thumbs 1 = 👍 / 0 = 👎),surveyandcommentfor surveys, pluspage_url,conversation_idandcreated_at.
The request
Each event is sent as a POST request with a JSON body and these headers:
| Header | Description |
|---|---|
Content-Type | application/json |
X-TalkingDot-Event | The event name, for example message.created. |
X-TalkingDot-Delivery | The ID of this delivery. It stays the same when the delivery is retried. |
X-TalkingDot-Signature | t=<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. |
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=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bdEvery body has the same envelope:
| Field | Type | Description |
|---|---|---|
id | string | Unique ID of the event, starting with evt_. It stays the same when the delivery is retried, so use it to skip duplicates. |
event | string | The event name. |
workspace_id | integer | The workspace the event happened in. |
created_at | timestamp | When the event happened (ISO 8601, UTC). |
data | object | The 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):
{
"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:
- Read the raw request body, byte for byte, before any JSON parsing. A body that has been parsed and re-encoded won't match.
- Split the header on
,to gett(a Unix timestamp in seconds) andv1(the signature). - Compute the HMAC-SHA256 of the string
t + "." + raw body, keyed with the endpoint's signing secret, as lowercase hex. - Compare it with
v1using a constant-time comparison. Reject the request if they differ. - We recommend also rejecting requests whose
tis 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
// 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.
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)
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 "", 200Ruby (Sinatra)
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
endKeep 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:
| Attempt | When |
|---|---|
| 1 | Right after the event |
| 2 | 1 minute after attempt 1 fails |
| 3 | 5 minutes later |
| 4 | 30 minutes later |
| 5 | 2 hours later |
| 6 | 6 hours later |
| 7 | 12 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
idof each event you process (it starts withevt_), with a unique index. If anidis already there, reply200and do nothing else. - Don't rely on events arriving in order. A retried delivery can arrive after a newer event. Compare the
created_atof the event with what you've stored, or fetch the current state from the REST API when order matters.
<?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
localhostis 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.createdandmessage.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:
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.