Before you start
- A Truplexy account on Pro and Enterprise.
- A server reachable over https, where the chat key and the code below run.
What you’ll connect:
Truplexy
Your team acts
A teammate replies to a ticket or escalates it.
Signed webhook
One POST
JSON body, event and delivery headers, and an HMAC signature.
Your server
Your receiver
Verifies the signature, answers 2xx fast, then delivers or posts.
Secrets the code reads from the environment:
| Variable | What it is |
|---|---|
TRUPLEXY_WEBHOOK_SECRET | The webhook’s signing secret (whsec_…). |
1. Create the webhook
- In the dashboard, open Integrations → Webhook and save an https URL. The first save creates the signing secret and shows it once; keep it as TRUPLEXY_WEBHOOK_SECRET.
- Handle the events in your receiver: verify the signature over the raw body, answer 2xx within 2.5 seconds, then do the work.
- Use “Send test” to deliver a ping, even while deliveries are paused.
Each bot has one webhook. The URL is https://, up to 2000 characters, with no user name, password or # fragment. Saving with enabled: false pauses deliveries. Events are sent for conversations started through the chat API.
Events
| Event | When | data |
|---|---|---|
message.created | A person replied to a ticket of a chat API conversation. | { conversation_id, message } |
ticket.updated | Such a ticket was opened, or its status or escalation changed. | { conversation_id, ticket } |
Each delivery is {id, type, created_at, data}. Notes, playground conversations and anything the integration did itself are not delivered.
Delivery
| Header | Value |
|---|---|
X-Truplexy-Event | The event type. |
X-Truplexy-Delivery | The event ID. A retry reuses it, so deduplicate on it. |
X-Truplexy-Signature | t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<raw body>"> |
Your receiver must answer 2xx within 2.5 seconds. After no answer, a 429 or a 5xx, the event is sent once more with the same delivery ID. A failed delivery shows in the webhook’s last delivery and never undoes the team’s change.
Verify the signature
Compute the HMAC-SHA256 of "<t>.<raw body>" with your signing secret, compare it in constant time, and reject a t more than 5 minutes old. Use the raw bytes of the body, not re-serialised JSON.
import crypto from "node:crypto";
export function verify(rawBody, header, secret) {
const { t, v1 } = Object.fromEntries(header.split(",").map((part) => part.split("=")));
const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
const fresh = Math.abs(Date.now() / 1000 - Number(t)) < 300;
return fresh && v1?.length === expected.length && crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(expected));
}import hashlib
import hmac
import time
def verify(raw_body: bytes, header: str, secret: str) -> bool:
parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
t, v1 = parts.get("t", ""), parts.get("v1", "")
expected = hmac.new(secret.encode(), t.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
fresh = t.isdigit() and abs(time.time() - int(t)) < 300
return fresh and hmac.compare_digest(v1, expected)<?php // verify.php
function verify_truplexy(string $rawBody, string $header, string $secret): bool {
$parts = [];
foreach (explode(',', $header) as $part) {
[$k, $v] = explode('=', $part, 2) + [1 => ''];
$parts[$k] = $v;
}
$t = $parts['t'] ?? '';
$expected = hash_hmac('sha256', "$t.$rawBody", $secret);
return ctype_digit($t) && abs(time() - (int) $t) < 300 && hash_equals($expected, $parts['v1'] ?? '');
}package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"strconv"
"strings"
"time"
)
func verify(rawBody []byte, header, secret string) bool {
var t, v1 string
for _, part := range strings.Split(header, ",") {
k, v, _ := strings.Cut(part, "=")
switch k {
case "t":
t = v
case "v1":
v1 = v
}
}
ts, err := strconv.ParseInt(t, 10, 64)
if err != nil || max(time.Now().Unix()-ts, ts-time.Now().Unix()) >= 300 {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(t + "."))
mac.Write(rawBody)
return hmac.Equal([]byte(hex.EncodeToString(mac.Sum(nil))), []byte(v1))
}import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HexFormat;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
public final class Verify {
public static boolean verify(byte[] rawBody, String header, String secret) throws Exception {
String t = "", v1 = "";
for (String part : header.split(",")) {
String[] kv = part.split("=", 2);
if (kv.length < 2) continue;
if (kv[0].equals("t")) t = kv[1];
if (kv[0].equals("v1")) v1 = kv[1];
}
if (!t.matches("\\d+") || Math.abs(System.currentTimeMillis() / 1000 - Long.parseLong(t)) >= 300) return false;
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
mac.update((t + ".").getBytes(StandardCharsets.UTF_8));
String expected = HexFormat.of().formatHex(mac.doFinal(rawBody));
return MessageDigest.isEqual(expected.getBytes(StandardCharsets.UTF_8), v1.getBytes(StandardCharsets.UTF_8));
}
}using System.Security.Cryptography;
using System.Text;
public static class Webhook
{
public static bool Verify(byte[] rawBody, string header, string secret)
{
var parts = header.Split(',')
.Select(p => p.Split('=', 2))
.Where(p => p.Length == 2)
.GroupBy(p => p[0])
.ToDictionary(g => g.Key, g => g.First()[1]);
if (!parts.TryGetValue("t", out var t) || !long.TryParse(t, out var ts) || !parts.TryGetValue("v1", out var v1)) return false;
if (Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - ts) >= 300) return false;
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
var expected = Convert.ToHexString(hmac.ComputeHash([.. Encoding.UTF8.GetBytes(t + "."), .. rawBody])).ToLowerInvariant();
return CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(expected), Encoding.UTF8.GetBytes(v1));
}
}require "openssl"
def verify(raw_body, header, secret)
parts = header.split(",").filter_map { |p| p.split("=", 2) if p.include?("=") }.to_h
t, v1 = parts["t"].to_s, 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}")
OpenSSL.fixed_length_secure_compare(expected, v1)
rescue ArgumentError # different lengths
false
endA receiver
A complete receiver: it verifies the delivery, acknowledges at once, then acts on message.created.
// Your team acted in Truplexy → act on it here.
app.post("/truplexy-webhook", express.raw({ type: "application/json" }), async (req, res) => {
const raw = req.body.toString("utf8");
if (!verify(raw, req.get("X-Truplexy-Signature") ?? "", process.env.TRUPLEXY_WEBHOOK_SECRET)) return res.sendStatus(401);
res.sendStatus(200); // answer within 2.5 seconds
const { type, data } = JSON.parse(raw);
const conversationId = data.conversation_id;
if (type === "message.created") { // your team replied
const text = data.message.content;
const agent = data.message.agent ?? "Support";
const thread = threadFor(conversationId);
// Deliver the reply to the customer's thread on your platform.
deliver(thread, text, agent);
}
});import crypto from "node:crypto";
export function verify(rawBody, header, secret) {
const { t, v1 } = Object.fromEntries(header.split(",").map((part) => part.split("=")));
const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex");
const fresh = Math.abs(Date.now() / 1000 - Number(t)) < 300;
return fresh && v1?.length === expected.length && crypto.timingSafeEqual(Buffer.from(v1), Buffer.from(expected));
}import json
import os
from fastapi import BackgroundTasks, Request, Response
from truplexy import thread_for
from verify import verify
# Your team acted in Truplexy → act on it here.
@app.post("/truplexy-webhook")
async def truplexy_webhook(request: Request, background: BackgroundTasks):
raw = await request.body()
if not verify(raw, request.headers.get("X-Truplexy-Signature", ""), os.environ["TRUPLEXY_WEBHOOK_SECRET"]):
return Response(status_code=401)
background.add_task(on_event, json.loads(raw)) # answer within 2.5 seconds
return Response(status_code=200)
async def on_event(event: dict):
data = event["data"]
conversation_id = data.get("conversation_id")
if event["type"] == "message.created": # your team replied
text = data["message"]["content"]
agent = data["message"].get("agent") or "Support"
thread = thread_for(conversation_id)
# Deliver the reply to the customer's thread on your platform.
await deliver(thread, text, agent)import hashlib
import hmac
import time
def verify(raw_body: bytes, header: str, secret: str) -> bool:
parts = dict(p.split("=", 1) for p in header.split(",") if "=" in p)
t, v1 = parts.get("t", ""), parts.get("v1", "")
expected = hmac.new(secret.encode(), t.encode() + b"." + raw_body, hashlib.sha256).hexdigest()
fresh = t.isdigit() and abs(time.time() - int(t)) < 300
return fresh and hmac.compare_digest(v1, expected)<?php // webhook.php — your team acted in Truplexy → act on it here.
require __DIR__ . '/truplexy.php';
require __DIR__ . '/verify.php';
$raw = file_get_contents('php://input');
if (!verify_truplexy($raw, $_SERVER['HTTP_X_TRUPLEXY_SIGNATURE'] ?? '', getenv('TRUPLEXY_WEBHOOK_SECRET'))) {
http_response_code(401);
exit;
}
http_response_code(200);
if (function_exists('fastcgi_finish_request')) fastcgi_finish_request(); // answer at once, keep working
$event = json_decode($raw, true);
$data = $event['data'];
$conversationId = $data['conversation_id'] ?? null;
if ($event['type'] === 'message.created') { // your team replied
$text = $data['message']['content'];
$agent = $data['message']['agent'] ?? 'Support';
$thread = thread_for($conversationId);
// Deliver the reply to the customer's thread on your platform.
deliver($thread, $text, $agent);
}<?php // verify.php
function verify_truplexy(string $rawBody, string $header, string $secret): bool {
$parts = [];
foreach (explode(',', $header) as $part) {
[$k, $v] = explode('=', $part, 2) + [1 => ''];
$parts[$k] = $v;
}
$t = $parts['t'] ?? '';
$expected = hash_hmac('sha256', "$t.$rawBody", $secret);
return ctype_digit($t) && abs(time() - (int) $t) < 300 && hash_equals($expected, $parts['v1'] ?? '');
}// Your team replied in Truplexy → deliver it on the platform.
http.HandleFunc("POST /truplexy-webhook", func(w http.ResponseWriter, r *http.Request) {
raw, _ := io.ReadAll(r.Body)
if !verify(raw, r.Header.Get("X-Truplexy-Signature"), os.Getenv("TRUPLEXY_WEBHOOK_SECRET")) {
w.WriteHeader(http.StatusUnauthorized)
return
}
w.WriteHeader(http.StatusOK) // answer within 2.5 seconds
var event struct {
Type string `json:"type"`
Data struct {
ConversationID string `json:"conversation_id"`
Message struct{ Content, Agent string }
}
}
if json.Unmarshal(raw, &event) != nil || event.Type != "message.created" {
return
}
agent := event.Data.Message.Agent
if agent == "" {
agent = "Support"
}
go deliver(threadFor(event.Data.ConversationID), event.Data.Message.Content, agent)
})
func deliver(thread, text, agent string) {
// Deliver the reply to the customer's thread on your platform.
sendToCustomer(thread, agent, text)
}package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"strconv"
"strings"
"time"
)
func verify(rawBody []byte, header, secret string) bool {
var t, v1 string
for _, part := range strings.Split(header, ",") {
k, v, _ := strings.Cut(part, "=")
switch k {
case "t":
t = v
case "v1":
v1 = v
}
}
ts, err := strconv.ParseInt(t, 10, 64)
if err != nil || max(time.Now().Unix()-ts, ts-time.Now().Unix()) >= 300 {
return false
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(t + "."))
mac.Write(rawBody)
return hmac.Equal([]byte(hex.EncodeToString(mac.Sum(nil))), []byte(v1))
}import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
// Your team acted in Truplexy → act on it here.
@RestController
class TruplexyWebhook {
private static final ObjectMapper JSON = new ObjectMapper();
private final ExecutorService work = Executors.newVirtualThreadPerTaskExecutor();
@PostMapping("/truplexy-webhook")
ResponseEntity<Void> receive(@RequestBody byte[] raw,
@RequestHeader(value = "X-Truplexy-Signature", defaultValue = "") String signature) throws Exception {
if (!Verify.verify(raw, signature, System.getenv("TRUPLEXY_WEBHOOK_SECRET"))) return ResponseEntity.status(401).build();
JsonNode event = JSON.readTree(raw);
work.submit(() -> { handle(event); return null; }); // answer within 2.5 seconds
return ResponseEntity.ok().build();
}
private void handle(JsonNode event) throws Exception {
String type = event.path("type").asText();
JsonNode data = event.path("data");
String conversationId = data.path("conversation_id").asText();
if (type.equals("message.created")) { // your team replied
String text = data.at("/message/content").asText();
String agent = data.at("/message/agent").asText("Support");
String thread = Truplexy.threadFor(conversationId);
// Deliver the reply to the customer's thread on your platform.
deliver(thread, text, agent);
}
}
}import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.HexFormat;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
public final class Verify {
public static boolean verify(byte[] rawBody, String header, String secret) throws Exception {
String t = "", v1 = "";
for (String part : header.split(",")) {
String[] kv = part.split("=", 2);
if (kv.length < 2) continue;
if (kv[0].equals("t")) t = kv[1];
if (kv[0].equals("v1")) v1 = kv[1];
}
if (!t.matches("\\d+") || Math.abs(System.currentTimeMillis() / 1000 - Long.parseLong(t)) >= 300) return false;
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
mac.update((t + ".").getBytes(StandardCharsets.UTF_8));
String expected = HexFormat.of().formatHex(mac.doFinal(rawBody));
return MessageDigest.isEqual(expected.getBytes(StandardCharsets.UTF_8), v1.getBytes(StandardCharsets.UTF_8));
}
}// Your team acted in Truplexy → act on it here.
app.MapPost("/truplexy-webhook", async (HttpRequest req) =>
{
using var buffer = new MemoryStream();
await req.Body.CopyToAsync(buffer);
var raw = buffer.ToArray();
var secret = Environment.GetEnvironmentVariable("TRUPLEXY_WEBHOOK_SECRET")!;
if (!Webhook.Verify(raw, req.Headers["X-Truplexy-Signature"].ToString(), secret)) return Results.Unauthorized();
var evt = JsonDocument.Parse(raw).RootElement.Clone();
_ = Task.Run(() => OnEvent(evt)); // answer within 2.5 seconds
return Results.Ok();
});
async Task OnEvent(JsonElement evt)
{
var type = evt.GetProperty("type").GetString();
var data = evt.GetProperty("data");
var conversationId = data.GetProperty("conversation_id").GetString()!;
if (type == "message.created") // your team replied
{
var message = data.GetProperty("message");
var text = message.GetProperty("content").GetString()!;
var agent = message.TryGetProperty("agent", out var a) && a.GetString() is { } name ? name : "Support";
var thread = Truplexy.ThreadFor(conversationId);
// Deliver the reply to the customer's thread on your platform.
await Deliver(thread, text, agent);
}
}using System.Security.Cryptography;
using System.Text;
public static class Webhook
{
public static bool Verify(byte[] rawBody, string header, string secret)
{
var parts = header.Split(',')
.Select(p => p.Split('=', 2))
.Where(p => p.Length == 2)
.GroupBy(p => p[0])
.ToDictionary(g => g.Key, g => g.First()[1]);
if (!parts.TryGetValue("t", out var t) || !long.TryParse(t, out var ts) || !parts.TryGetValue("v1", out var v1)) return false;
if (Math.Abs(DateTimeOffset.UtcNow.ToUnixTimeSeconds() - ts) >= 300) return false;
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
var expected = Convert.ToHexString(hmac.ComputeHash([.. Encoding.UTF8.GetBytes(t + "."), .. rawBody])).ToLowerInvariant();
return CryptographicOperations.FixedTimeEquals(Encoding.UTF8.GetBytes(expected), Encoding.UTF8.GetBytes(v1));
}
}require_relative "truplexy"
require_relative "verify"
# Your team acted in Truplexy → act on it here.
post "/truplexy-webhook" do
raw = request.body.read
halt 401 unless verify(raw, request.env["HTTP_X_TRUPLEXY_SIGNATURE"].to_s, ENV.fetch("TRUPLEXY_WEBHOOK_SECRET"))
event = JSON.parse(raw)
Thread.new { on_event(event) } # answer within 2.5 seconds
200
end
def on_event(event)
data = event["data"]
conversation_id = data["conversation_id"]
if event["type"] == "message.created" # your team replied
text = data.dig("message", "content")
agent = data.dig("message", "agent") || "Support"
thread = Truplexy.thread_for(conversation_id)
# Deliver the reply to the customer's thread on your platform.
deliver(thread, text, agent)
end
endrequire "openssl"
def verify(raw_body, header, secret)
parts = header.split(",").filter_map { |p| p.split("=", 2) if p.include?("=") }.to_h
t, v1 = parts["t"].to_s, 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}")
OpenSSL.fixed_length_secure_compare(expected, v1)
rescue ArgumentError # different lengths
false
endTest it
- Open Integrations → Webhook and use Send test. Your receiver should get a
pingand answer 2xx, even while deliveries are paused. - Reply to a ticket from Tickets and check that
message.createdarrives with a valid signature. - Escalate a ticket and check that
ticket.updatedarrives.
Troubleshooting
| Problem | Fix |
|---|---|
| 401 from your own receiver | Verify the HMAC over the raw request body, not the parsed JSON, and use the current secret. Rotating it replaces the old one. |
| Deliveries show as failed | Your receiver must answer 2xx within 2.5 seconds. After no answer, a 429 or a 5xx, the event is sent once more with the same delivery ID. |
| Duplicate events | A retry reuses X-Truplexy-Delivery. Deduplicate on it. |
| No events for something your integration did | By design: notes, playground conversations and anything the integration did itself are not delivered. |
Still stuck? Email hello@truplexy.com with the request_id from the error.