Integrate
Webhooks
Webhooks send events from your tenant to an HTTPS URL of your choice as JSON. Each request is signed with HMAC-SHA256 using a secret unique to that endpoint.
#Setup
Add an endpoint
On the Webhooks page in the admin console, enter your URL and pick the events you want (requires
webhooks:manage). The URL must usehttps; it may not contain a username/password or a#fragment, and private addresses or local names such aslocalhostare rejected. A tenant can have up to 20 endpoints.Store the signing secret
The secret (
whsec_…) is shown only once, when the endpoint is created. If you lose it, use Rotate secret to get a new one; the old secret stops working immediately.Send a test
Send test delivers a
webhook.testevent right away and shows the result (HTTP status, duration, error).
#Events
| Event | When | Main fields in data |
|---|---|---|
conversation.opened | A new conversation was opened. | conversation { id, status, websiteId, createdAt } |
conversation.status_changed | The conversation status changed (e.g. AI_ACTIVE, HUMAN_ACTIVE, CLOSED). | status, previousStatus, ownerType |
conversation.assigned | The conversation was assigned or transferred to an agent. | assignedAgent { id, displayName }, previousAgentId, reason, status |
message.created | A message was added (visitor, agent, AI or system). Internal notes are not sent. | message (MessageDto), sequence |
action.updated | A bonus action changed status. | action { id, actionType, status, amountMinor, currency, campaignName, … } |
incident.opened | Operations monitoring opened an incident. | incidentId, kind, subject, rule, severity, title |
incident.resolved | An incident was resolved. | incidentId, kind, subject, rule, severity, title |
#Request and body
Every delivery is a POST request:
POST /hooks/2people HTTP/1.1
Content-Type: application/json
User-Agent: 2people-webhooks/1
X-2people-Event: webhook.test
X-2people-Event-Id: 0b8f3c52-5d3e-4a59-9a43-6c1f0f7f2a10
X-2people-Delivery: 6f1d2a7c-0e8b-4c55-8f0a-2d9e3b1c4a77
X-2people-Signature: t=1767225600,v1=5d41402abc4b2a76b9719d911017c592…
{
"id": "0b8f3c52-5d3e-4a59-9a43-6c1f0f7f2a10",
"type": "webhook.test",
"occurredAt": "2026-01-01T00:00:00.000Z",
"tenantId": "8c7e…",
"data": { "message": "Test event from 2people" }
}| Name | Type | Description |
|---|---|---|
idrequired | string (uuid) | Event id, same as X-2people-Event-Id. Use it to drop duplicates. |
typerequired | string | Event type, same as X-2people-Event. |
occurredAtrequired | string (date-time) | When the event happened. |
tenantIdrequired | string (uuid) | The tenant the event belongs to. |
datarequired | object | Event-specific content. Conversation, message and action events also carry conversationId and visitorId. |
X-2people-Delivery identifies the delivery; it stays the same across retries of that delivery.
#Verifying the signature
The X-2people-Signature header has the form t=<unix seconds>,v1=<signature>. The signature is the lower-case hex HMAC-SHA256 of <t>.<raw body>, keyed with the secret string itself (whsec_… as is, not decoded).
- Split
tandv1out of the header. - Take the raw request body (do not parse and re-serialise it) and compute HMAC-SHA256 over
t + "." + body. - Compare the result with
v1in constant time. - Check that
tis recent to block replays (the examples allow 5 minutes).
import { createHmac, timingSafeEqual } from 'node:crypto';
import express from 'express';
const SECRET = process.env.TWOPEOPLE_WEBHOOK_SECRET; // whsec_…
const TOLERANCE_SECONDS = 300;
export function verifySignature(rawBody, header, secret = SECRET) {
const parts = Object.fromEntries(
String(header ?? '')
.split(',')
.map((p) => p.trim().split('=')),
);
const t = Number(parts.t);
if (!Number.isInteger(t) || !parts.v1) return false;
if (Math.abs(Date.now() / 1000 - t) > TOLERANCE_SECONDS) return false;
const expected = createHmac('sha256', secret).update(`${t}.${rawBody}`).digest();
const given = Buffer.from(parts.v1, 'hex');
return given.length === expected.length && timingSafeEqual(given, expected);
}
const app = express();
// Verify against the raw bytes: re-serialised JSON will not match.
app.post('/hooks/2people', express.raw({ type: 'application/json' }), (req, res) => {
if (!verifySignature(req.body, req.get('X-2people-Signature'))) {
return res.sendStatus(400);
}
const event = JSON.parse(req.body.toString('utf8'));
// Deduplicate on event.id, then process asynchronously.
res.sendStatus(204);
});#Responses and retries
Any 2xx response within 10 seconds counts as success. Redirects are not followed. Failed deliveries are retried after these delays (counted from the previous attempt):
| Attempt | Delay |
|---|---|
| 1 | — |
| 2 | 1 min |
| 3 | 5 min |
| 4 | 30 min |
| 5 | 2 h |
| 6 | 6 h |
| 7 | 12 h |
If the seventh attempt also fails, the delivery is marked failed. You can retry any delivery by hand from the delivery log in the console.