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

  1. 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 use https; it may not contain a username/password or a # fragment, and private addresses or local names such as localhost are rejected. A tenant can have up to 20 endpoints.

  2. 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.

  3. Send a test

    Send test delivers a webhook.test event right away and shows the result (HTTP status, duration, error).

#Events

EventWhenMain fields in data
conversation.openedA new conversation was opened.conversation { id, status, websiteId, createdAt }
conversation.status_changedThe conversation status changed (e.g. AI_ACTIVE, HUMAN_ACTIVE, CLOSED).status, previousStatus, ownerType
conversation.assignedThe conversation was assigned or transferred to an agent.assignedAgent { id, displayName }, previousAgentId, reason, status
message.createdA message was added (visitor, agent, AI or system). Internal notes are not sent.message (MessageDto), sequence
action.updatedA bonus action changed status.action { id, actionType, status, amountMinor, currency, campaignName, … }
incident.openedOperations monitoring opened an incident.incidentId, kind, subject, rule, severity, title
incident.resolvedAn incident was resolved.incidentId, kind, subject, rule, severity, title

#Request and body

Every delivery is a POST request:

HTTP
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" }
}
NameTypeDescription
idrequiredstring (uuid)Event id, same as X-2people-Event-Id. Use it to drop duplicates.
typerequiredstringEvent type, same as X-2people-Event.
occurredAtrequiredstring (date-time)When the event happened.
tenantIdrequiredstring (uuid)The tenant the event belongs to.
datarequiredobjectEvent-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).

  1. Split t and v1 out of the header.
  2. Take the raw request body (do not parse and re-serialise it) and compute HMAC-SHA256 over t + "." + body.
  3. Compare the result with v1 in constant time.
  4. Check that t is 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):

AttemptDelay
1—
21 min
35 min
430 min
52 h
66 h
712 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.