#Webhooks (Version 2)
Status: Available. Outgoing webhooks of the previous version (v1) are not part of the public contract and have been replaced by version 2. The version 2 signature is not a shared secret but the platform's electronic signature (JWS, Ed25519): subscriptions have no secrets.
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 the platform's signature. - You verify the signature with the platform's public key (served by the JWKS key set), 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 |
GET /webhooks/{id}/deliveries | Delivery log | webhooks:manage |
GET /.well-known/jwks.json | Public keys used to verify delivery signatures. No authorization | not required |
#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/webhooks |
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 "$API_BASE_URL/webhooks" \
-H "Authorization: Bearer $API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 6b1f0b8e-0f55-4a39-9b0b-1c1d6a1d9a01" \
-d '{"url": "https://hooks.example.com/webhooks", "events": ["lead.created", "article.ready"]}'
201 response (illustrative):
{
"id": "whk_EXAMPLE01",
"url": "https://hooks.example.com/webhooks",
"events": ["lead.created", "article.ready"],
"include_pii": false,
"description": null,
"status": "active",
"created_at": "2026-10-11T08:15:30Z",
"updated_at": "2026-10-11T08:15:30Z"
}
A subscription has no secret: there is nothing to store. Every delivery is signed by the platform key of your cell, and you verify the signature with the public key from the JWKS key set. When you repeat a request with the same Idempotency-Key, the response is the same. 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 |
Webhook-Event-Id | The unique event identifier (the same in all retries of one delivery) |
Webhook-Event-Type | The event type, for example lead.created |
Webhook-Timestamp | The signing time in Unix seconds (UTC). Set anew on every attempt |
Webhook-Signature | A JWS signature (EdDSA, Ed25519) with a detached payload: <header>..<signature> (format) |
#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 Webhook-Event-Id) |
type | string | The event type (equal to Webhook-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. In the EN and ID cells, when the JWE format is configured, the personal data fields in such an event arrive encrypted with the recipient's key (see the page "JWE format for personal data fields" in the EN and ID cell menus).
| 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 (JWS, Ed25519)
Every delivery carries the platform's electronic signature: JWS (RFC 7515), algorithm EdDSA (Ed25519), detached payload (Appendix F of RFC 7515). The signature is verified with a public key that the platform publishes itself. The private key stays with the platform, and you store no secret: there is nothing to leak from your system and nothing to hand over.
The three delivery headers:
Webhook-Event-Id: evt_EXAMPLE01
Webhook-Timestamp: 1760170530
Webhook-Signature: eyJhbGciOiJFZERTQSIsImtpZCI6IkVYQU1QTEVfS0lEIn0..EXAMPLE_SIGNATURE_NOT_REAL
| Header | Meaning |
|---|---|
Webhook-Event-Id | The event identifier. It does not change across retries of one delivery |
Webhook-Timestamp | The signing time, Unix seconds, UTC. Set anew on every attempt |
Webhook-Signature | A compact JWS with an empty middle part: BASE64URL(protected header)..BASE64URL(signature). The protected header contains alg = EdDSA and kid, the key identifier |
What is signed. A string of three parts joined by dots, encoded as UTF-8:
<Webhook-Event-Id>.<Webhook-Timestamp>.<request body>
The body is taken whole, exactly as it arrived (bytes, before JSON parsing). The event identifier, the time, and the body are all part of the signature, so it protects against content tampering, event type substitution, identifier substitution, and re-attaching an old signature to a new body. The signature itself carries no payload (the middle part is empty): you supply it yourself.
#Verification and the JWKS key set
The cell's public keys are served by GET /api/public/v1/.well-known/jwks.json (JWKS format, RFC 7517). The request needs no access key. The response can be cached: the header is Cache-Control: max-age=300. Each key in the set:
{
"keys": [
{
"kty": "OKP",
"crv": "Ed25519",
"x": "EXAMPLE_PUBLIC_KEY_BASE64URL",
"kid": "EXAMPLE_KID",
"alg": "EdDSA",
"use": "sig"
}
]
}
Verification algorithm:
- Get the raw body of the request (not re-serialized JSON) and the three headers.
- Check freshness:
|now − Webhook-Timestamp|must not exceed 300 seconds (5 minutes). Otherwise reject the delivery as a replay. - From
Webhook-Signature, take the protected header and the signature; pick the key from the JWKS set bykid. If thatkidis not in your cache, re-fetch the set once. - Build the signed string
<Webhook-Event-Id>.<Webhook-Timestamp>.<body>, encode it as BASE64URL, and insert it between the two dots of the signature. The result is an ordinary compact JWS. - Verify it with a JOSE library using the public key, allowing only
EdDSA. - Check that
Webhook-Event-Idhas not been processed yet (protection against 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.
The examples below verify only the signature, and only with a library: do not write your own implementation of the algorithm. Replace the event identifier store (seen) with Redis or a database with a TTL of at least 72 hours.
#Node.js (jose, Express)
import express from 'express';
import { base64url, compactVerify, createRemoteJWKSet } from 'jose';
const app = express();
// The key set is cached by the library; for an unknown kid it re-fetches itself.
const JWKS = createRemoteJWKSet(new URL(`${process.env.API_BASE_URL}/.well-known/jwks.json`));
const TOLERANCE_SEC = 300;
const seen = new Map(); // replace with Redis/a DB with TTL of at least 72 h
// Important: the raw body, not express.json()
app.post('/webhooks', express.raw({ type: 'application/json', limit: '1mb' }), async (req, res) => {
const eventId = req.get('Webhook-Event-Id') ?? '';
const timestamp = req.get('Webhook-Timestamp') ?? '';
const signature = req.get('Webhook-Signature') ?? '';
const body = req.body; // Buffer
if (!/^\d{1,12}$/.test(timestamp) || Math.abs(Date.now() / 1000 - Number(timestamp)) > TOLERANCE_SEC) {
return res.sendStatus(400);
}
const m = /^([\w-]+)\.\.([\w-]+)$/.exec(signature);
if (!eventId || !m) return res.sendStatus(400);
// The payload is detached from the signature: build it ourselves and insert it between the dots.
const payload = Buffer.concat([Buffer.from(`${eventId}.${timestamp}.`), body]);
try {
await compactVerify(`${m[1]}.${base64url.encode(payload)}.${m[2]}`, JWKS, { algorithms: ['EdDSA'] });
} catch {
return res.sendStatus(400);
}
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'));
queueMicrotask(() => handle(event)); // put it on your own queue quickly, respond right away
res.sendStatus(200);
});
function handle(event) { /* your logic, idempotent by event.id */ }
app.listen(3000);
#Python (PyJWT, Flask)
In PyJWT the verification is done by PyJWS, not jwt.decode(): the signed payload is not JSON but a string of three parts. The cryptography package is required.
import base64
import json
import os
import time
import jwt
import requests
from flask import Flask, abort, request
from jwt import PyJWK, PyJWS
app = Flask(__name__)
JWKS_URL = os.environ["API_BASE_URL"] + "/.well-known/jwks.json"
TOLERANCE_SEC = 300
seen = {} # replace with Redis/a DB with TTL of at least 72 h
_jwks = {"keys": [], "at": 0.0}
def b64url(data: bytes) -> str:
return base64.urlsafe_b64encode(data).rstrip(b"=").decode()
def key_for(kid: str):
"""A key from the JWKS set; the set is cached for 5 minutes and re-fetched on an unknown kid."""
for attempt in (0, 1):
if attempt == 1 or time.time() - _jwks["at"] > 300:
_jwks.update(keys=requests.get(JWKS_URL, timeout=5).json()["keys"], at=time.time())
for jwk in _jwks["keys"]:
if jwk.get("kid") == kid:
return PyJWK.from_dict(jwk).key
return None
@app.post("/webhooks")
def handle_webhook():
event_id = request.headers.get("Webhook-Event-Id", "")
timestamp = request.headers.get("Webhook-Timestamp", "")
signature = request.headers.get("Webhook-Signature", "")
body = request.get_data() # raw bytes
if not timestamp.isdigit() or abs(time.time() - int(timestamp)) > TOLERANCE_SEC:
abort(400)
parts = signature.split(".")
if not event_id or len(parts) != 3 or parts[1] != "":
abort(400)
try:
header = json.loads(base64.urlsafe_b64decode(parts[0] + "=" * (-len(parts[0]) % 4)))
key = key_for(header.get("kid", ""))
if key is None:
abort(400)
payload = f"{event_id}.{timestamp}.".encode() + body
# The payload is detached from the signature: build it ourselves and insert it between the dots.
PyJWS().decode(f"{parts[0]}.{b64url(payload)}.{parts[2]}", key, algorithms=["EdDSA"])
except (jwt.PyJWTError, ValueError):
abort(400)
if event_id in seen:
return "", 200
seen[event_id] = time.time()
event = json.loads(body)
# put the event on a queue and respond quickly
return "", 200
#PHP (libsodium)
The firebase/php-jwt library expects the payload as JSON, but here the payload is not JSON, so in PHP the signature is verified with the standard sodium extension (bundled with PHP since 7.2).
<?php
const TOLERANCE = 300;
function b64urlDecode(string $s): string|false {
return base64_decode(strtr($s, '-_', '+/') . str_repeat('=', (4 - strlen($s) % 4) % 4), true);
}
function b64urlEncode(string $s): string {
return rtrim(strtr(base64_encode($s), '+/', '-_'), '=');
}
// Key set: cache it for 5 minutes (APCu, a file) and re-fetch on an unknown kid.
function publicKey(string $kid, bool $refresh = false): ?string {
static $keys = null;
if ($keys === null || $refresh) {
$set = json_decode(file_get_contents(getenv('API_BASE_URL') . '/.well-known/jwks.json'), true);
$keys = [];
foreach ($set['keys'] ?? [] as $jwk) {
if (($jwk['kty'] ?? '') === 'OKP' && ($jwk['crv'] ?? '') === 'Ed25519') {
$keys[$jwk['kid']] = b64urlDecode($jwk['x']);
}
}
}
return $keys[$kid] ?? ($refresh ? null : publicKey($kid, true));
}
$body = file_get_contents('php://input'); // raw body
$eventId = $_SERVER['HTTP_WEBHOOK_EVENT_ID'] ?? '';
$timestamp = $_SERVER['HTTP_WEBHOOK_TIMESTAMP'] ?? '';
$signature = $_SERVER['HTTP_WEBHOOK_SIGNATURE'] ?? '';
$parts = explode('.', $signature);
if ($eventId === '' || !ctype_digit($timestamp) || abs(time() - (int) $timestamp) > TOLERANCE
|| count($parts) !== 3 || $parts[1] !== '') {
http_response_code(400); exit;
}
$header = json_decode((string) b64urlDecode($parts[0]), true);
$pub = is_array($header) && ($header['alg'] ?? '') === 'EdDSA' ? publicKey((string) ($header['kid'] ?? '')) : null;
$sig = b64urlDecode($parts[2]);
// The payload is detached from the signature: what is signed is "<protected header>.<BASE64URL(payload)>".
$input = $parts[0] . '.' . b64urlEncode($eventId . '.' . $timestamp . '.' . $body);
if ($pub === null || $sig === false || !sodium_crypto_sign_verify_detached($sig, $input, $pub)) {
http_response_code(400); exit;
}
// Check $eventId in your store (unique index); on a duplicate: http_response_code(200); exit;
$event = json_decode($body, true);
// put the event 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 Webhook-Timestamp (300 seconds) | An intercepted old request will not pass |
Storing Webhook-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 |
| Verification with a JOSE library rather than manual string comparison | No custom signature comparison or format parsing code |
| HTTPS only and a closed endpoint | The signature protects integrity and the sender's authenticity, not the confidentiality of the body |
When one event is delivered again,
Webhook-Event-Iddoes not change, whileWebhook-Timestampand the signature are computed anew. The time tolerance applies to the current attempt.
#Platform key rotation
The platform's signing key may change. You do not need to configure anything if you:
- pick the key by the
kidin theWebhook-Signatureheader rather than taking "the first key of the set"; - cache the set for no longer than 5 minutes (as the server serves it) and re-fetch it on an unfamiliar
kid.
During the transition the set holds both the previous and the new key. If the platform's signing key is temporarily unavailable, deliveries are not sent unsigned: they are deferred for 15 minutes without consuming retries (the log shows error: signing_key_unavailable), and GET /.well-known/jwks.json responds 503 service_unavailable.
#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) |
| Signing key unavailable | A delivery is never sent unsigned: it is deferred for 15 minutes without consuming retries (the log shows signing_key_unavailable) |
| 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
Webhook-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.
#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 |
service_unavailable | 503 | GET /.well-known/jwks.json: the cell's signing key is temporarily unavailable (deliveries are deferred in the meantime) |
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.