Skip to content

Setup guide · Webhooks

Receive Truplexy webhooks

Signed events sent to your server when your team replies to a ticket or a ticket changes, so your integration can deliver the reply or post the escalation.

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:

Secrets the code reads from the environment:

VariableWhat it is
TRUPLEXY_WEBHOOK_SECRETThe webhook’s signing secret (whsec_…).

1. Create the webhook

  1. 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.
  2. Handle the events in your receiver: verify the signature over the raw body, answer 2xx within 2.5 seconds, then do the work.
  3. 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

EventWhendata
message.createdA person replied to a ticket of a chat API conversation.{ conversation_id, message }
ticket.updatedSuch 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

HeaderValue
X-Truplexy-EventThe event type.
X-Truplexy-DeliveryThe event ID. A retry reuses it, so deduplicate on it.
X-Truplexy-Signaturet=<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.

verify.js
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));
}
verify.py
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)
verify.php
<?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'] ?? '');
}
verify.go
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))
}
Verify.java
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));
    }
}
Verify.cs
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));
    }
}
verify.rb
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
end

A receiver

A complete receiver: it verifies the delivery, acknowledges at once, then acts on message.created.

webhook.js
// 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);
  }
});
verify.js
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));
}
webhook.py
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)
verify.py
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)
webhook.php
<?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);
}
verify.php
<?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'] ?? '');
}
webhook.go
// 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)
}
verify.go
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))
}
TruplexyWebhook.java
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);
        }
    }
}
Verify.java
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));
    }
}
Webhook.cs
// 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);
    }
}
Verify.cs
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));
    }
}
webhook.rb
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
end
verify.rb
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
end

Test it

  • Open Integrations → Webhook and use Send test. Your receiver should get a ping and answer 2xx, even while deliveries are paused.
  • Reply to a ticket from Tickets and check that message.created arrives with a valid signature.
  • Escalate a ticket and check that ticket.updated arrives.

Troubleshooting

ProblemFix
401 from your own receiverVerify 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 failedYour 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 eventsA retry reuses X-Truplexy-Delivery. Deduplicate on it.
No events for something your integration didBy 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.

Next steps