Before you start
- A Truplexy account on Starter, Pro and Enterprise (Limited on Starter, Full on Pro, Full on Enterprise).
- A server reachable over https, where the chat key and the code below run.
What you’ll connect:
Your product
Any surface
An app, a kiosk, a messaging platform: anywhere your customer is.
Your server
Chat API calls
Holds the chat key. Sends the customer’s message, shows the reply.
Truplexy
The assistant
Answers, opens tickets, hands off to your team.
1. Issue a chat key
- In the dashboard, open Integrations → Add a channel → REST API.
- Add a Custom channel, so tickets show where they came from, and issue its chat key.
- Store it on your server as
TRUPLEXY_CHAT_KEY, in.envor your host’s settings. Never put it in a website, mobile app or theme file: anyone could copy it.
2. Call the chat API
- Identify the sender with PUT /customer, then create a conversation with customer_id for each customer thread.
- Send the customer’s message to the conversation.
- Render the reply according to its status.
| Method | Endpoint | Behavior |
|---|---|---|
getProfile() | GET /profile | Business and bot names and pictures. |
upsertCustomer(input) | PUT /customer | Find or create a customer by contacts; 200 existing, 201 new. |
createConversation(input?) | POST /conversations | Start a conversation with optional customer_id and channel_id. |
getConversation(id) | GET /conversations/{id} | Read the latest 200 messages, oldest first. |
sendMessage(id, text) | POST /conversations/{id}/messages | Send 1–4000 characters; inspect status and nullable reply. |
handoff(id) | POST /conversations/{id}/handoff | Flag for the team; the AI keeps replying. |
escalate(id) | POST /conversations/{id}/escalate | Open or escalate a ticket; the AI goes silent. |
resume(id) | POST /conversations/{id}/resume | End escalation; the ticket stays open. Safe to repeat. |
createTicket(id, input?) | POST /conversations/{id}/ticket | Open or return the open ticket; optional subject and priority. |
closeTicket(id) | POST /conversations/{id}/ticket/close | Close the ticket, end escalation, clear handoff; 404 if none ever existed. |
sendReply(id, input) | POST /conversations/{id}/replies | Record a staff reply; external_id deduplicates retries (201 or 200). |
getRealtimeToken(id?) | POST /realtime/token | Server topics; give the browser only its conversation_topic. |
A channel-bound key supplies the channel automatically; channel_id can override it. Disabled channels return CHANNEL_DISABLED.
3. Add the server code
Server code only: never call the chat API from a browser or an app. The helper wraps every call above.
// npm i @truplexy/server — server code only.
import { createChatClient } from "@truplexy/server";
const chat = createChatClient();
const customer = await chat.upsertCustomer({ contacts: [{ type: "app", value: "user-42" }, { type: "email", value: "ann@example.com" }] });
const conv = await chat.createConversation({ customer_id: customer.id });
const r = await chat.sendMessage(conv.id, "Where is my order?");
console.log(r.status, r.reply, r.sources);import asyncio
from truplexy import call, create_conversation
async def main():
conv = await create_conversation({"contacts": [{"type": "app", "value": "user-42"}]})
r = await call(f"/conversations/{conv['id']}/messages", {"message": "Where is my order?"})
print(r["status"], r["reply"], r["sources"])
asyncio.run(main())# truplexy.py — runs on your server only.
# TRUPLEXY_CHAT_KEY lives in your secret manager, never in a browser or app.
import os
import httpx
API = "https://api.truplexy.com/v2"
_http = httpx.AsyncClient(
base_url=API,
headers={"Authorization": f"Bearer {os.environ['TRUPLEXY_CHAT_KEY']}"},
timeout=60,
)
class TruplexyApiError(Exception):
def __init__(self, response, error):
super().__init__(error.get("message", "Truplexy request failed"))
self.status, self.code = response.status_code, error.get("code", "UPSTREAM_ERROR")
self.request_id = error.get("request_id") or response.headers.get("x-request-id")
self.details = error
async def call(path: str, body: dict | None = None, method: str = "POST") -> dict:
res = await _http.request(method, path, **({"json": body} if body is not None else {}))
try:
data = res.json()
except ValueError:
data = {}
if res.is_error:
raise TruplexyApiError(res, data.get("error", {}))
return data
async def upsert_customer(customer: dict) -> dict:
try:
return await call("/customer", customer, "PUT")
except TruplexyApiError as e:
if e.status != 409 or e.code != "CUSTOMER_EXISTS":
raise
return await call("/customer", customer, "PUT")
async def create_conversation(customer=None, channel_id=None) -> dict:
opening = {"channel_id": channel_id} if channel_id else {}
if customer is not None:
opening["customer_id"] = (await upsert_customer(customer))["id"]
return await call("/conversations", opening)
# One Truplexy conversation per customer thread. Use your database in production.
threads: dict[str, str] = {}
def thread_for(conversation_id: str) -> str | None:
return next((t for t, c in threads.items() if c == conversation_id), None)
async def ask(thread: str, text: str, customer=None, channel_id=None) -> dict:
conv = threads.get(thread)
if not conv:
conv = threads[thread] = (await create_conversation(customer, channel_id))["id"]
r = await call(f"/conversations/{conv}/messages", {"message": text[:4000]})
# "escalated" (a person has it) and "context" have no reply: send nothing.
return {"reply": r.get("reply"), "status": r["status"], "conversation_id": conv}
async def handoff(conversation_id: str) -> dict:
return await call(f"/conversations/{conversation_id}/handoff")<?php
require __DIR__ . '/truplexy.php';
$conv = create_conversation(['contacts' => [['type' => 'app', 'value' => 'user-42']]]);
$r = truplexy("/conversations/{$conv['id']}/messages", ['message' => 'Where is my order?']);
echo $r['status'], ': ', $r['reply'], PHP_EOL;<?php // truplexy.php — runs on your server only.
// TRUPLEXY_CHAT_KEY lives in your secret manager, never in a browser or app.
const TRUPLEXY_API = 'https://api.truplexy.com/v2';
class TruplexyApiError extends RuntimeException {
public function __construct(public int $status, public array $details, public string $requestId = '') {
parent::__construct($details['message'] ?? 'Truplexy request failed');
}
public function apiCode(): string { return $this->details['code'] ?? 'UPSTREAM_ERROR'; }
}
/** Calls an API with a JSON body and returns the decoded answer. */
function post_json(string $url, ?array $body = null, array $headers = [], string $method = 'POST'): array {
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 60,
CURLOPT_HTTPHEADER => array_merge(['Content-Type: application/json'], $headers),
]);
if ($method !== 'GET') curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body ?? new stdClass()));
$requestId = '';
curl_setopt($ch, CURLOPT_HEADERFUNCTION, function ($ch, $line) use (&$requestId) {
if (stripos($line, 'X-Request-ID:') === 0) $requestId = trim(substr($line, 13));
return strlen($line);
});
$res = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$data = json_decode((string) $res, true) ?? [];
if ($res === false || $status >= 300) throw new TruplexyApiError($status ?: 502, $data['error'] ?? [], $data['error']['request_id'] ?? $requestId);
return $data;
}
function truplexy(string $path, ?array $body = null, string $method = 'POST'): array {
return post_json(TRUPLEXY_API . $path, $body, ['Authorization: Bearer ' . getenv('TRUPLEXY_CHAT_KEY')], $method);
}
function upsert_customer(array $customer): array {
try { return truplexy('/customer', $customer, 'PUT'); }
catch (TruplexyApiError $e) {
if ($e->status !== 409 || $e->apiCode() !== 'CUSTOMER_EXISTS') throw $e;
return truplexy('/customer', $customer, 'PUT');
}
}
function create_conversation(?array $customer = null, ?string $channelId = null): array {
$opening = $channelId ? ['channel_id' => $channelId] : [];
if ($customer !== null) {
$known = upsert_customer($customer);
if (empty($known['id'])) throw new TruplexyApiError(502, ['code' => 'UPSTREAM_ERROR', 'message' => 'Missing customer ID']);
$opening['customer_id'] = $known['id'];
}
return truplexy('/conversations', $opening ?: null);
}
// One Truplexy conversation per customer thread. Use your database in production.
function threads(?array $save = null): array {
$file = __DIR__ . '/threads.json';
if ($save !== null) file_put_contents($file, json_encode($save), LOCK_EX);
return $save ?? (json_decode(@file_get_contents($file) ?: '{}', true));
}
function thread_for(string $conversationId): ?string {
return array_search($conversationId, threads(), true) ?: null;
}
function ask(string $thread, string $text, ?array $customer = null, ?string $channelId = null): array {
$threads = threads();
$id = $threads[$thread] ??= create_conversation($customer, $channelId)['id'];
threads($threads);
$r = truplexy("/conversations/$id/messages", ['message' => mb_substr($text, 0, 4000)]);
// "escalated" (a person has it) and "context" have no reply: send nothing.
return ['reply' => $r['reply'], 'status' => $r['status'], 'conversationId' => $id];
}
function handoff(string $conversationId): array {
return truplexy("/conversations/$conversationId/handoff");
}func main() {
conversationID, err := createConversation(identify("app", "user-42"))
if err != nil { log.Fatal(err) }
var r struct {
Status, Reply string
Sources []map[string]any
}
if err := call("/conversations/"+conversationID+"/messages", map[string]string{"message": "Where is my order?"}, &r); err != nil {
log.Fatal(err)
}
fmt.Println(r.Status, r.Reply, len(r.Sources))
}// truplexy.go — runs on your server only.
// TRUPLEXY_CHAT_KEY lives in your secret manager, never in a browser or app.
package main
import (
"bytes"
"encoding/json"
"io"
"net/http"
"os"
"sync"
"time"
)
const truplexyAPI = "https://api.truplexy.com/v2"
var httpClient = &http.Client{Timeout: 60 * time.Second}
type APIError struct {
Status int
Code, Message, RequestID string
Details map[string]any
}
func (e *APIError) Error() string { return e.Code + ": " + e.Message }
// sendJSON calls an API with a JSON body, decoding the answer into out when given.
func sendJSON(method, url string, body any, headers map[string]string, out ...any) error {
var payload io.Reader = http.NoBody
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return err
}
payload = bytes.NewReader(b)
}
req, err := http.NewRequest(method, url, payload)
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
for k, v := range headers {
req.Header.Set(k, v)
}
res, err := httpClient.Do(req)
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode >= 300 {
msg, _ := io.ReadAll(res.Body)
var data struct { Error map[string]any }
json.Unmarshal(msg, &data)
code, _ := data.Error["code"].(string)
message, _ := data.Error["message"].(string)
requestID, _ := data.Error["request_id"].(string)
if requestID == "" { requestID = res.Header.Get("X-Request-ID") }
return &APIError{res.StatusCode, code, message, requestID, data.Error}
}
if len(out) == 0 {
return nil
}
return json.NewDecoder(res.Body).Decode(out[0])
}
func call(path string, body, out any) error { return callMethod("POST", path, body, out) }
func callMethod(method, path string, body, out any) error {
auth := map[string]string{"Authorization": "Bearer " + os.Getenv("TRUPLEXY_CHAT_KEY")}
if out == nil {
return sendJSON(method, truplexyAPI+path, body, auth)
}
return sendJSON(method, truplexyAPI+path, body, auth, out)
}
func upsertCustomer(customer map[string]any) (string, error) {
var result struct { ID string }
err := callMethod("PUT", "/customer", customer, &result)
if e, ok := err.(*APIError); ok && e.Status == 409 && e.Code == "CUSTOMER_EXISTS" {
err = callMethod("PUT", "/customer", customer, &result)
}
if err == nil && result.ID == "" { err = &APIError{Status: 502, Code: "UPSTREAM_ERROR", Message: "Missing customer ID"} }
return result.ID, err
}
type ConversationOptions struct { Customer map[string]any; ChannelID string }
func identify(kind, value string) ConversationOptions {
return ConversationOptions{Customer: map[string]any{"contacts": []map[string]string{{"type": kind, "value": value}}}}
}
func createConversation(options ConversationOptions) (string, error) {
opening := map[string]string{}
if options.ChannelID != "" { opening["channel_id"] = options.ChannelID }
if options.Customer != nil {
id, err := upsertCustomer(options.Customer)
if err != nil { return "", err }
opening["customer_id"] = id
}
var result struct { ID string }
err := call("/conversations", opening, &result)
return result.ID, err
}
// One Truplexy conversation per customer thread. Use your database in production.
var (
mu sync.Mutex
threads = map[string]string{}
)
func threadFor(conversationID string) string {
mu.Lock()
defer mu.Unlock()
for t, c := range threads {
if c == conversationID {
return t
}
}
return ""
}
type Answer struct {
Reply string `json:"reply"` // empty for "escalated" and "context": send nothing
Status string `json:"status"`
ConversationID string `json:"conversationId"`
}
func ask(thread, text string, options ...ConversationOptions) (Answer, error) {
mu.Lock()
id, ok := threads[thread]
mu.Unlock()
if !ok {
option := ConversationOptions{}
if len(options) > 0 { option = options[0] }
var err error
id, err = createConversation(option)
if err != nil { return Answer{}, err }
mu.Lock()
threads[thread] = id
mu.Unlock()
}
if r := []rune(text); len(r) > 4000 {
text = string(r[:4000])
}
var a Answer
err := call("/conversations/"+id+"/messages", map[string]string{"message": text}, &a)
a.ConversationID = id
return a, err
}
func handoff(conversationID string) error {
return call("/conversations/"+conversationID+"/handoff", map[string]any{}, nil)
}Register the http.HandleFunc routes inside main(); the functions sit beside it in package main.
import java.util.Map;
public class Example {
public static void main(String[] args) throws Exception {
var conv = Truplexy.createConversation(Map.of("contacts", java.util.List.of(Map.of("type", "app", "value", "user-42"))), null);
var r = Truplexy.call("/conversations/" + conv.get("id").asText() + "/messages", Map.of("message", "Where is my order?"));
System.out.println(r.get("status").asText() + ": " + r.path("reply").asText());
}
}// Truplexy.java — runs on your server only.
// TRUPLEXY_CHAT_KEY lives in your secret manager, never in a browser or app.
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.Map;
import java.util.HashMap;
import java.util.concurrent.ConcurrentHashMap;
public final class Truplexy {
private static final String API = "https://api.truplexy.com/v2";
private static final HttpClient HTTP = HttpClient.newHttpClient();
private static final ObjectMapper JSON = new ObjectMapper();
// One Truplexy conversation per customer thread. Use your database in production.
private static final Map<String, String> THREADS = new ConcurrentHashMap<>();
/** reply is null for "escalated" and "context": send nothing. */
public record Answer(String reply, String status, String conversationId) {}
public static final class ApiError extends RuntimeException {
public final int status;
public final String code, requestId;
public final JsonNode details;
ApiError(int status, JsonNode details, String requestId) {
super(details.path("message").asText("Truplexy request failed"));
this.status = status; this.details = details;
this.code = details.path("code").asText("UPSTREAM_ERROR");
this.requestId = details.path("request_id").asText(requestId);
}
}
public static JsonNode call(String path, Object body) throws Exception { return call(path, body, "POST"); }
public static JsonNode call(String path, Object body, String method) throws Exception {
var req = HttpRequest.newBuilder(URI.create(API + path))
.timeout(Duration.ofSeconds(60))
.header("Authorization", "Bearer " + System.getenv("TRUPLEXY_CHAT_KEY"))
.header("Content-Type", "application/json")
.method(method, body == null ? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofString(JSON.writeValueAsString(body)))
.build();
var res = HTTP.send(req, HttpResponse.BodyHandlers.ofString());
JsonNode data;
try { data = JSON.readTree(res.body()); } catch (Exception e) { data = JSON.createObjectNode(); }
if (res.statusCode() >= 300) throw new ApiError(res.statusCode(), data.path("error"), res.headers().firstValue("X-Request-ID").orElse(null));
return data;
}
public static JsonNode upsertCustomer(Object customer) throws Exception {
try { return call("/customer", customer, "PUT"); }
catch (ApiError e) {
if (e.status != 409 || !e.code.equals("CUSTOMER_EXISTS")) throw e;
return call("/customer", customer, "PUT");
}
}
public static JsonNode createConversation(Object customer, String channelId) throws Exception {
var opening = new HashMap<String, Object>();
if (customer != null) opening.put("customer_id", upsertCustomer(customer).get("id").asText());
if (channelId != null) opening.put("channel_id", channelId);
return call("/conversations", opening);
}
public static Answer ask(String thread, String text) throws Exception { return ask(thread, text, null, null); }
public static Answer ask(String thread, String text, Object customer, String channelId) throws Exception {
String id = THREADS.get(thread);
if (id == null) {
id = createConversation(customer, channelId).get("id").asText();
THREADS.put(thread, id);
}
var message = text.length() > 4000 ? text.substring(0, 4000) : text;
var r = call("/conversations/" + id + "/messages", Map.of("message", message));
return new Answer(r.path("reply").asText(null), r.get("status").asText(), id);
}
public static String threadFor(String conversationId) {
return THREADS.entrySet().stream()
.filter(e -> e.getValue().equals(conversationId))
.map(Map.Entry::getKey)
.findFirst().orElse(null);
}
public static JsonNode handoff(String conversationId) throws Exception {
return call("/conversations/" + conversationId + "/handoff", null);
}
}var conv = await Truplexy.CreateConversation(new { contacts = new[] { new { type = "app", value = "user-42" } } });
var id = conv.GetProperty("id").GetString();
var r = await Truplexy.Call($"/conversations/{id}/messages", new { message = "Where is my order?" });
Console.WriteLine($"{r.GetProperty("status")}: {r.GetProperty("reply")}");// Truplexy.cs — runs on your server only.
// TRUPLEXY_CHAT_KEY lives in your secret manager, never in a browser or app.
using System.Collections.Concurrent;
using System.Net.Http.Json;
using System.Text.Json;
public static class Truplexy
{
const string Api = "https://api.truplexy.com/v2";
static readonly HttpClient Http = new()
{
Timeout = TimeSpan.FromSeconds(60),
DefaultRequestHeaders = { Authorization = new("Bearer", Environment.GetEnvironmentVariable("TRUPLEXY_CHAT_KEY")) },
};
// One Truplexy conversation per customer thread. Use your database in production.
static readonly ConcurrentDictionary<string, string> Threads = new();
/// <summary>Reply is null for "escalated" and "context": send nothing.</summary>
public record Answer(string? Reply, string Status, string ConversationId);
public sealed class ApiError(int status, JsonElement details, string? requestId) : Exception(details.TryGetProperty("message", out var m) ? m.GetString() : "Truplexy request failed")
{
public int Status { get; } = status;
public JsonElement Details { get; } = details;
public string Code { get; } = details.TryGetProperty("code", out var c) ? c.GetString()! : "UPSTREAM_ERROR";
public string? RequestId { get; } = details.TryGetProperty("request_id", out var r) ? r.GetString() : requestId;
}
public static async Task<JsonElement> Call(string path, object? body = null, string method = "POST")
{
using var req = new HttpRequestMessage(new HttpMethod(method), Api + path);
if (body is not null) req.Content = JsonContent.Create(body);
using var res = await Http.SendAsync(req);
JsonElement data;
try { data = await res.Content.ReadFromJsonAsync<JsonElement>(); }
catch (JsonException) { data = JsonSerializer.SerializeToElement(new { }); }
if (!res.IsSuccessStatusCode) {
var details = data.TryGetProperty("error", out var error) ? error : JsonSerializer.SerializeToElement(new { });
throw new ApiError((int)res.StatusCode, details, res.Headers.TryGetValues("X-Request-ID", out var ids) ? ids.FirstOrDefault() : null);
}
return data;
}
public static async Task<JsonElement> UpsertCustomer(object customer)
{
try { return await Call("/customer", customer, "PUT"); }
catch (ApiError e) when (e.Status == 409 && e.Code == "CUSTOMER_EXISTS") { return await Call("/customer", customer, "PUT"); }
}
public static async Task<JsonElement> CreateConversation(object? customer = null, string? channelId = null)
{
var opening = new Dictionary<string, string>();
if (customer is not null) opening["customer_id"] = (await UpsertCustomer(customer)).GetProperty("id").GetString()!;
if (channelId is not null) opening["channel_id"] = channelId;
return await Call("/conversations", opening);
}
public static async Task<Answer> Ask(string thread, string text, object? customer = null, string? channelId = null)
{
if (!Threads.TryGetValue(thread, out var id))
Threads[thread] = id = (await CreateConversation(customer, channelId)).GetProperty("id").GetString()!;
var message = text.Length > 4000 ? text[..4000] : text;
var r = await Call($"/conversations/{id}/messages", new { message });
return new Answer(r.GetProperty("reply").GetString(), r.GetProperty("status").GetString()!, id);
}
public static string? ThreadFor(string conversationId) =>
Threads.FirstOrDefault(t => t.Value == conversationId).Key;
public static Task<JsonElement> Handoff(string conversationId) => Call($"/conversations/{conversationId}/handoff");
}require_relative "truplexy"
conv = Truplexy.create_conversation({ contacts: [{ type: "app", value: "user-42" }] })
r = Truplexy.call("/conversations/#{conv["id"]}/messages", { message: "Where is my order?" })
puts "#{r["status"]}: #{r["reply"]}"# truplexy.rb — runs on your server only.
# TRUPLEXY_CHAT_KEY lives in your secret manager, never in a browser or app.
require "json"
require "net/http"
module Truplexy
API = "https://api.truplexy.com/v2"
# One Truplexy conversation per customer thread. Use your database in production.
THREADS = {}
class ApiError < StandardError
attr_reader :status, :code, :request_id, :details
def initialize(res, error)
super(error.fetch("message", "Truplexy request failed"))
@status, @code, @details = res.code.to_i, error.fetch("code", "UPSTREAM_ERROR"), error
@request_id = error["request_id"] || res["X-Request-ID"]
end
end
def self.call(path, body = nil, method = "POST")
uri = URI(API + path)
req = Net::HTTP.const_get(method.capitalize).new(uri)
req["Authorization"] = "Bearer #{ENV.fetch("TRUPLEXY_CHAT_KEY")}"
req["Content-Type"] = "application/json"
req.body = body.to_json unless body.nil?
res = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", read_timeout: 60) { |http| http.request(req) }
data = JSON.parse(res.body) rescue {}
raise ApiError.new(res, data.fetch("error", {})) unless res.is_a?(Net::HTTPSuccess)
data
end
def self.upsert_customer(customer)
call("/customer", customer, "PUT")
rescue ApiError => e
raise unless e.status == 409 && e.code == "CUSTOMER_EXISTS"
call("/customer", customer, "PUT")
end
def self.create_conversation(customer = nil, channel_id = nil)
opening = {}
unless customer.nil?
opening[:customer_id] = upsert_customer(customer).fetch("id")
raise "Missing customer ID" if opening[:customer_id].to_s.empty?
end
opening[:channel_id] = channel_id unless channel_id.nil?
call("/conversations", opening)
end
def self.ask(thread, text, customer = nil, channel_id = nil)
id = THREADS[thread] ||= create_conversation(customer, channel_id)["id"]
r = call("/conversations/#{id}/messages", { message: text[0, 4000] })
# "escalated" and "context" have no reply: send nothing.
{ reply: r["reply"], status: r["status"], conversation_id: id }
end
def self.thread_for(conversation_id) = THREADS.key(conversation_id)
def self.handoff(conversation_id) = call("/conversations/#{conversation_id}/handoff")
endReading the reply
Show reply for answered, clarification and no-answer statuses. Offer a handoff button for handoff_offered: a handoff notifies the team while the AI continues, while an escalation silences the AI. For escalated and context the reply is null: send nothing.
Build a custom channel
To connect a platform without a ready-made guide (a messaging app, a game, an in-product chat), relay its messages through the calls above and treat it as its own channel:
- Under Integrations → Your channels, add a Custom channel and issue its chat key, so its tickets show where they came from.
- Choose a contact type for the people on your platform, such as
appfor your own user IDs, and send it withPUT /customer. Add any other contacts you know, like an email address, so the same person is matched across channels. - Keep one Truplexy conversation per thread on your platform, and store that mapping in your database.
- Set up the webhook and deliver your team’s replies from
message.createdback on your platform. Webhooks are on Pro and Enterprise.
// 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
endAdd TRUPLEXY_WEBHOOK_SECRET (the webhook’s signing secret, whsec_…) to your environment for the receiver.
Customer identity
Truplexy matches people by their contacts, so one person across channels is one customer. Contact types are yours to choose: app works well for your own users' IDs; add any others you know, such as an email address. Create the conversation with the returned customer_id, and its tickets inherit the customer and the channel.
- Send 1–20 contacts as {type, value, label?, primary?}, with optional name and metadata. Names allow 120 characters; metadata must be a JSON object up to 4 KB.
- Contact types are 2–32 lowercase letters, digits, hyphens or underscores, starting with a letter. Values allow 1–254 characters; labels allow 60.
- Emails are lowercased. Phone numbers lose spaces, dashes, dots and parentheses and must contain 5–20 digits, optionally beginning with +. Other values keep their case.
- Each contact belongs to one customer within one bot. Mark at most one primary contact per type; otherwise the first becomes primary. Existing primary flags survive an upsert.
- An upsert finds any matching contact, adds missing contacts, updates supplied name and metadata, and marks the customer seen. Omit optional fields to preserve them.
- CUSTOMER_CONFLICT means the contacts belong to different customers: nothing is changed. Resolve it in Customers. CUSTOMER_EXISTS is a race: the SDK and helpers retry once.
- Pass the returned id as customer_id when starting a conversation. Tickets inherit that customer and the conversation’s channel. Existing conversations are not relinked.
Test it
- Send a message through your integration. The reply should arrive within a few seconds.
- For an identified customer, open Customers and check the contact and conversation link. Any tickets should carry that customer too.
- Ask for a person. The conversation shows up under Tickets → Handoffs.
- Take it over and reply from the ticket. Your reply should appear through your webhook.
Troubleshooting
| Problem | Fix |
|---|---|
CHANNEL_DISABLED | The channel the key is bound to is switched off. Turn it on under Integrations → Your channels. |
PLAN_LIMIT_REACHED | This month’s AI replies or tokens are used up. Upgrade or add tokens under Settings → Plan. |
CUSTOMER_CONFLICT | The contacts you sent belong to different customers. Correct them in Customers; nothing is changed until you do. |
reply is null | A person has taken over the conversation (status escalated), or the message only carried context. Send nothing; the team’s reply arrives through the webhook. |
| Nothing arrives after your team replies | Check the webhook under Integrations → Webhook: it must be https, answer 2xx within 2.5 seconds, and verify X-Truplexy-Signature against the raw body. |
Still stuck? Email hello@truplexy.com with the request_id from the error.