#Webhooks (Version 2)
Status: Available. Outgoing webhooks of the previous version (v1) are not part of the public contract and will be replaced.
Purpose: receive platform events at your HTTPS address instead of polling the API: a new message, a lead, a finished article, a strategist report.
Access: the webhooks:manage scope for managing subscriptions. Paid: no. Personal data: none by default (events are "thin"); the include_pii subscription flag includes personal data in lead, contact, and conversation events and requires the integration to have read scopes for those resources. Event content requested with a key requires the scopes of the corresponding resources.
The JSON examples are illustrative: the names of optional fields will be refined in OpenAPI.
#How it works
- You create a subscription: an
https://…address and a list of events. - The platform writes the event to a durable queue in the same transaction as the data change (the event is not lost even if delivery fails).
- A worker delivers the event with a
POSTrequest to your address, with a signature. - You verify the signature, respond quickly with
2xx, and process the event on your side. - On failure, the platform retries delivery: up to 8 retries with increasing pauses (schedule). A delivery that is not accepted even after the eighth retry becomes a "dead letter" (
state: dead): it stays in the log for 30 days. There is no automatic redelivery: fetch anything missing from the API by the resource identifier.
#Managing subscriptions
| Method and path | Purpose | Scope |
|---|---|---|
POST /webhooks | Create a subscription | webhooks:manage |
GET /webhooks | List your integration's subscriptions | webhooks:manage |
GET /webhooks/{id} | A single subscription | webhooks:manage |
PATCH /webhooks/{id} | Change the address, events, include_pii, description, status (active or paused) | webhooks:manage |
DELETE /webhooks/{id} | Delete a subscription | webhooks:manage |
POST /webhooks/{id}/test | Send a webhook.test test event | webhooks:manage |
POST /webhooks/{id}/rotate-secret | Replace the signing secret (with a 24-hour overlap) | webhooks:manage |
GET /webhooks/{id}/deliveries | Delivery log | webhooks:manage |
#Creating a subscription
POST /webhooks, headers: Authorization, Content-Type: application/json, Idempotency-Key.
| Parameter | Type | Required | Constraints | Example |
|---|---|---|---|---|
url | string | yes | https, port 443, no IP address in place of a host name, no redirects | https://hooks.example.com/sapport |
events | array of strings | yes | values from the event list | ["lead.created", "message.created"] |
include_pii | boolean | no | false by default. true is allowed only if events contains only lead.*, contact.*, conversation.* events; the integration must have leads:read and (for conversation events) conversations:read. See include_pii | false |
description | string | no | up to 200 characters, for your convenience | CRM, production |
curl -sS -X POST "$SAPPORT_BASE_URL/webhooks" \
-H "Authorization: Bearer $SAPPORT_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 6b1f0b8e-0f55-4a39-9b0b-1c1d6a1d9a01" \
-d '{"url": "https://hooks.example.com/sapport", "events": ["lead.created", "article.ready"]}'
201 response (illustrative):
{
"id": "whk_EXAMPLE01",
"url": "https://hooks.example.com/sapport",
"events": ["lead.created", "article.ready"],
"include_pii": false,
"description": null,
"status": "active",
"secret": "whsec_EXAMPLE_NOT_A_REAL_SECRET",
"created_at": "2026-10-11T08:15:30Z",
"updated_at": "2026-10-11T08:15:30Z",
"secret_rotated_at": null,
"previous_secret_expires_at": null
}
Save the signing secret right away: it is shown once, when the subscription is created and when it is rotated, and is not returned afterwards when you read the subscription. The secret is stored encrypted on the platform side. When you repeat a request with the same Idempotency-Key, the response is the same but secret is null: if you lost the secret, run rotate-secret. An integration can have at most 10 subscriptions (otherwise 409 invalid_state); subscriptions are visible only to the integration that created them.
#Address requirements
The address is checked by SSRF protection at creation and on every connection:
- scheme
https, port443; - a host name, not an IP address; addresses in internal and reserved ranges are rejected (DNS is checked at connection time);
- redirects (
3xx) are not followed: a3xxresponse counts as a failure; - the certificate must be valid.
A violation at creation yields 422 validation_failed.
#Events
Events are divided into groups. All of them are delivered to the subscribers that listed them in events.
#Conversations and the AI seller
| Event | When | Content with include_pii: true |
|---|---|---|
conversation.created | A conversation was created | yes (personal data) |
message.created | A message appeared in the conversation (from the customer, the AI, or an operator). For a customer message, data contains ai_reply: {status, reason?} | no: the message text is not included in the event, read it through the API |
conversation.handoff_requested | A handoff of the conversation to an operator was requested. data contains lead_id if the conversation has a lead | yes |
conversation.summary_ready | The conversation summary is ready | yes |
The conversation.handoff_requested event is the only one for handoff to an operator: the handoff.requested event (from the previous webhook version) and lead.handoff_requested are not used in v2.
#Leads and CRM
| Event | When | Content with include_pii: true |
|---|---|---|
lead.created | A lead was created | yes (the contact is personal data) |
lead.updated | Lead fields changed (the event contains the changed fields and the version) | yes |
lead.scored | The lead was scored: score, level, reason, flags | yes |
lead.status_changed | The lead status changed | yes |
lead.action_requested | An agent asks the external CRM to perform an action (change a status, assign, create a task, send a nudge, hand off to an operator), with a reason and evidence | yes |
task.created | A task was created | no |
task.completed | A task was closed | no |
contact.merged | Contacts were merged | yes |
crm.mode_changed | The tenant switched the CRM mode | no |
For more on CRM events and modes, see crm/01-modes.md and crm/04-agent-actions.md.
#Articles
| Event | When |
|---|---|
article.ready | The article is ready (generated and passed review). If you publish yourself, pick up the article on this event |
article.published | Publication is confirmed (the address of the published page was received) |
article.generation.failed | Article generation ended with an error |
#Strategist
| Event | When |
|---|---|
strategist.report.ready | A new strategist report is ready |
strategist.turn.completed | A turn in the conversation with the strategist finished |
#Service events
| Event | When |
|---|---|
operation.completed | An asynchronous operation finished (succeeded, failed, canceled). data contains the operation envelope |
api_key.expiring | A key's validity is about to end: the event arrives 30 and 7 days before expiry |
webhook.test | A test delivery requested with POST /webhooks/{id}/test; livemode: false |
The list may grow. Treat unknown event types as "accepted, ignore" (respond 2xx); otherwise new events will pile up in retries.
#What you receive
#Delivery headers
| Header | Value |
|---|---|
Content-Type | application/json |
Sapport-Event-Id | The unique event identifier (the same in all retries of one delivery) |
Sapport-Event-Type | The event type, for example lead.created |
Sapport-Signature | The signature: t=<unix-time>,v1=<hex>; during secret rotation, two v1 values |
#Body: the "thin" payload by default
By default the event contains only identifiers and a type: no personal data. You request whatever is missing through the API with your own key. This way personal data is not sent to an arbitrary address without a key.
The event envelope is the same for all types:
{
"id": "evt_EXAMPLE01",
"type": "lead.created",
"api_version": "2026-10-01",
"created_at": "2026-10-11T08:15:30Z",
"livemode": true,
"data": {
"lead_id": "lead_EXAMPLE01",
"contact_id": "ctc_EXAMPLE01",
"conversation_id": "cnv_EXAMPLE01",
"version": 1
}
}
| Field | Type | Description |
|---|---|---|
id | string | The event identifier (equal to Sapport-Event-Id) |
type | string | The event type (equal to Sapport-Event-Type) |
api_version | string | The event schema version: 2026-10-01 |
created_at | string | The event time, UTC |
livemode | boolean | true for production data; false for the webhook.test test event and sandbox data |
data | object | Resource identifiers. For lead events it contains version, the version of the lead itself |
After receiving the event, request the resource: GET /leads/{lead_id} (scope leads:read).
#include_pii: the full payload
For addresses to which the tenant has entrusted personal data (for example, its own CRM), the subscription gets the include_pii: true flag turned on: the content (the contact, the lead fields) arrives directly in the event.
| Condition | Value |
|---|---|
| Events | Only lead.*, contact.*, conversation.*. The flag does not apply to other types; message.created never contains the message text |
| Scope | The integration must have leads:read and/or conversations:read, depending on the types of the selected events |
| Logs | Turning it on is recorded in the disclosure register; each such delivery goes into the personal data disclosure log (Cells and data) |
Turn on
include_piionly for addresses to which you are entitled to transfer your customers' personal data. In the RU cell this requires a mandatory IP allowlist for the integration, the hosting country of the receiving system, and the basis for the transfer; personal data is transferred only to recipients in Russia, and for any other countryinclude_piiis not granted to the integration (403 pii_transfer_not_allowed, Cells and data).
#Signature
Every delivery is signed. The header:
Sapport-Signature: t=1760170530,v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd
| Part | Meaning |
|---|---|
t | The signing time (Unix seconds, UTC) |
v1 | HMAC-SHA256 in hexadecimal over the string t + "." + body with your signing secret |
What is signed. The entire request body exactly as it arrived (bytes, before JSON parsing). The body contains the event identifier, type, and schema version, so the signature protects against both content tampering and event type substitution.
#Verification algorithm
- Get the raw body of the request (not re-serialized JSON).
- Parse
Sapport-Signature: thetvalue and allv1values. - Compute
HMAC_SHA256(secret, t + "." + body)in hex. - Compare with each
v1in constant time. A match with any one is enough. - Check freshness:
|now − t|must not exceed the tolerance (5 minutes is recommended). Otherwise reject it as a replay. - Check that
Sapport-Event-Idhas not been processed yet (protection against replays and duplicates). - Only then parse the JSON.
If the signature does not match, respond with a 4xx (for example, 400) and do not process the body.
#Node.js (Express)
import crypto from 'node:crypto';
import express from 'express';
const app = express();
const SECRETS = [process.env.SAPPORT_WEBHOOK_SECRET]; // during rotation, both secrets
const TOLERANCE_SEC = 300;
const seen = new Map(); // replace with Redis/a DB with TTL ≥ 72 h
// Important: the raw body, not express.json()
app.post('/sapport', express.raw({ type: 'application/json', limit: '1mb' }), (req, res) => {
const header = req.get('Sapport-Signature') || '';
const parts = Object.fromEntries(
header.split(',').map((p) => p.trim().split('=')).filter((kv) => kv.length === 2),
);
const t = Number(parts.t);
const sigs = header.split(',').map((p) => p.trim()).filter((p) => p.startsWith('v1=')).map((p) => p.slice(3));
if (!t || sigs.length === 0) return res.sendStatus(400);
if (Math.abs(Date.now() / 1000 - t) > TOLERANCE_SEC) return res.sendStatus(400);
const body = req.body; // Buffer
const ok = SECRETS.some((secret) => {
const expected = crypto.createHmac('sha256', secret).update(`${t}.`).update(body).digest();
return sigs.some((hex) => {
const got = Buffer.from(hex, 'hex');
return got.length === expected.length && crypto.timingSafeEqual(got, expected);
});
});
if (!ok) return res.sendStatus(400);
const eventId = req.get('Sapport-Event-Id');
if (seen.has(eventId)) return res.sendStatus(200); // duplicate: acknowledge, do not process
seen.set(eventId, Date.now());
const event = JSON.parse(body.toString('utf8'));
// put it on your own queue quickly and respond
queueMicrotask(() => handle(event));
res.sendStatus(200);
});
function handle(event) { /* your logic, idempotent by event.id */ }
app.listen(3000);
#Python (Flask)
import hmac
import hashlib
import os
import time
from flask import Flask, request, abort
app = Flask(__name__)
SECRETS = [os.environ["SAPPORT_WEBHOOK_SECRET"]] # during rotation, both secrets
TOLERANCE_SEC = 300
seen = {} # replace with Redis/a DB with TTL ≥ 72 h
@app.post("/sapport")
def sapport_webhook():
header = request.headers.get("Sapport-Signature", "")
items = [p.strip().split("=", 1) for p in header.split(",") if "=" in p]
t = next((v for k, v in items if k == "t"), None)
sigs = [v for k, v in items if k == "v1"]
if not t or not sigs:
abort(400)
if abs(time.time() - int(t)) > TOLERANCE_SEC:
abort(400)
body = request.get_data() # raw bytes
signed = t.encode() + b"." + body
ok = any(
hmac.compare_digest(hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest(), sig)
for secret in SECRETS
for sig in sigs
)
if not ok:
abort(400)
event_id = request.headers.get("Sapport-Event-Id")
if event_id in seen:
return "", 200
seen[event_id] = time.time()
event = request.get_json(force=True)
# put it on a queue and respond quickly
return "", 200
#PHP
<?php
$secrets = [getenv('SAPPORT_WEBHOOK_SECRET')]; // during rotation, both secrets
$tolerance = 300;
$body = file_get_contents('php://input'); // raw body
$header = $_SERVER['HTTP_SAPPORT_SIGNATURE'] ?? '';
$t = null;
$sigs = [];
foreach (explode(',', $header) as $part) {
$kv = explode('=', trim($part), 2);
if (count($kv) !== 2) continue;
if ($kv[0] === 't') $t = (int) $kv[1];
if ($kv[0] === 'v1') $sigs[] = $kv[1];
}
if (!$t || !$sigs || abs(time() - $t) > $tolerance) {
http_response_code(400);
exit;
}
$ok = false;
foreach ($secrets as $secret) {
$expected = hash_hmac('sha256', $t . '.' . $body, $secret);
foreach ($sigs as $sig) {
if (hash_equals($expected, $sig)) { $ok = true; }
}
}
if (!$ok) {
http_response_code(400);
exit;
}
$eventId = $_SERVER['HTTP_SAPPORT_EVENT_ID'] ?? '';
// check $eventId in your store (unique index); on a duplicate: http_response_code(200); exit;
$event = json_decode($body, true);
// put it on a queue and respond quickly
http_response_code(200);
#Replay protection
The signature contains a time, and the event has a unique identifier. Use both:
| Measure | What it gives |
|---|---|
A tolerance on time t (5 minutes is recommended) | An intercepted old request will not pass |
Storing Sapport-Event-Id with a TTL of at least 72 hours | A duplicate delivery or a malicious replay within the window is ignored. Platform retries stretch over almost two days, so the TTL must cover them |
| Strict constant-time comparison | No leak through comparison timing |
| HTTPS only and a closed endpoint | The signature protects integrity, not the confidentiality of the body |
When one event is delivered again,
Sapport-Event-Iddoes not change, whiletandv1are computed anew. The time tolerance applies to the current attempt.
#Delivery, retries, ordering
| Property | Value |
|---|---|
| Timeout | 10 seconds for a response |
| Success | Any 2xx response received within 10 seconds |
| Failure | Any other response, a timeout, a connection error, a 3xx response |
| Retries | The first attempt and up to 8 retries. Pauses before retries: 1 min, 5 min, 15 min, 1 h, 3 h, 6 h, 12 h, 24 h |
| After the eighth retry | The delivery becomes a "dead letter": it stays in the log and can be resent |
| Suspended exchange | If the tenant wallet is negative or blocked, deliveries are not sent but deferred for 15 minutes without consuming retries; an event that does not survive until exchange resumes (retention 30 days) is closed as a "dead letter" with the reason wallet_suspended (Wallet) |
| Ordering | Not guaranteed. Events may arrive out of the order in which they occurred |
| Duplicates | Possible (at-least-once delivery) |
#Idempotent processing
Because of retries and reordering, the handler must be idempotent:
- Deduplicate by
Sapport-Event-Id(oridin the body). - Do not rely on ordering. For lead events, compare
version: apply a change only if the version is greater than the one already stored; otherwise ignore it as stale. - Read the current state. If the event is "thin", a
GETrequest shows the resource's current state regardless of the order in which events arrived. - Respond quickly. Acknowledge with
2xxright after enqueuing; do heavy work asynchronously. Long processing inside the handler causes timeouts and extra retries. - Return
5xxfor temporary failures and2xxfor permanent ones (for example, an unknown type), so you do not get endless retries.
#Delivery log
GET /webhooks/{id}/deliveries (scope webhooks:manage, a cursor-paginated list: Conventions). Retained for 30 days.
| Field | Type | Description |
|---|---|---|
id | string | The delivery identifier |
event_id | string | The event identifier |
state | string | pending, delivered, failed, dead |
next_attempt_at | string or null | The time of the next attempt |
created_at | string | The time the delivery was queued |
attempt | number | The attempt number (1 is the first, then retries) |
status_code | number or null | The subscriber's HTTP response code; null if there was no response |
duration_ms | number | The attempt duration in milliseconds |
error | string or null | A short failure reason code: http_<code>, network_error, redirect_not_followed, https_only, private_address, and so on. No addresses and no response text |
delivered_at | string or null | The time of successful delivery; null if the attempt failed |
Use the log for debugging and for recovering missed events: an undelivered event can be fetched from the API by the resource identifier. "Dead letters" are visible in the log by state: dead. Delivery states: pending, delivered, failed (awaiting a retry), dead. Filter: ?state=. The list is sorted from newest to oldest.
#Test delivery
POST /webhooks/{id}/test (requires an Idempotency-Key; the subscription must be active, otherwise 409 invalid_state) queues a webhook.test event with a correct signature to the subscription's address and responds 202 {"event_id", "type": "webhook.test", "status": "queued"}. The delivery goes out within the next minute; check the result in the log. Only this subscription receives the test event. The test event takes the same delivery path as a production one, which makes it convenient for checking network reachability, your signature verification code, and the response. The difference from a production event is livemode: false in the body.
#Secret rotation
You can replace the secret with no downtime, in the dashboard or with a POST /webhooks/{id}/rotate-secret request (scope webhooks:manage). The request requires an Idempotency-Key. The new secret is shown once in the response (the secret field; null on a repeated request).
- Request rotation of the subscription secret.
- For 24 hours, two secrets are valid, the new one and the previous one, and every delivery is signed with both:
Sapport-Signature: t=…,v1=<new>,v1=<old>. The client parses allv1values and accepts the delivery if any one matches. - Deploy the new secret on your side. Until you are done, verify the signature with both secrets (
SECRETSin the examples above). - After the window ends, the previous secret stops being valid and one
v1remains in the header.
If a secret has leaked: rotate immediately, and compare the delivery log with the log of the events you accepted during the leak period.
#Errors
| Code | HTTP | When |
|---|---|---|
scope_missing | 403 | The key lacks webhooks:manage |
validation_failed | 422 | The address is not https:443, points to an IP, or the event is unknown; include_pii: true for events outside lead.*, contact.*, conversation.* |
scope_missing | 403 | include_pii: true without leads:read/conversations:read on the integration |
pii_transfer_not_allowed | 403 | RU cell: include_pii: true with a hosting country of the receiving system outside Russia |
not_found | 404 | The subscription is not found, or belongs to another tenant or another integration |
invalid_state | 409 | More than 10 subscriptions per integration; a test event for an inactive subscription |
feature_not_in_plan | 403 | The plan does not include webhooks |
See the catalog.
#Notes
- A subscriber receives events of its own tenant only.
- In version v2 the
message.createdevent is always "thin": the message text is not part of it. Version v1 webhooks sent the customer message text; v2 does not. - One address per system and separate subscriptions for production and test environments are recommended.
- The
api_key.expiringevent arrives 30 and 7 days before a key expires; it is convenient to route it to your team's notification channel so you do not miss a rotation. - A subscription to events that contain only identifiers requires only
webhooks:manage; to read a resource by its identifier, you need that resource's scopes.