◆ Sapport for developers API reference Error catalog OpenAPI
Sections

#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

  1. You create a subscription: an https://… address and a list of events.
  2. 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).
  3. A worker delivers the event with a POST request to your address, with a signature.
  4. You verify the signature, respond quickly with 2xx, and process the event on your side.
  5. 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 pathPurposeScope
POST /webhooksCreate a subscriptionwebhooks:manage
GET /webhooksList your integration's subscriptionswebhooks:manage
GET /webhooks/{id}A single subscriptionwebhooks:manage
PATCH /webhooks/{id}Change the address, events, include_pii, description, status (active or paused)webhooks:manage
DELETE /webhooks/{id}Delete a subscriptionwebhooks:manage
POST /webhooks/{id}/testSend a webhook.test test eventwebhooks:manage
POST /webhooks/{id}/rotate-secretReplace the signing secret (with a 24-hour overlap)webhooks:manage
GET /webhooks/{id}/deliveriesDelivery logwebhooks:manage

#Creating a subscription

POST /webhooks, headers: Authorization, Content-Type: application/json, Idempotency-Key.

ParameterTypeRequiredConstraintsExample
urlstringyeshttps, port 443, no IP address in place of a host name, no redirectshttps://hooks.example.com/sapport
eventsarray of stringsyesvalues from the event list["lead.created", "message.created"]
include_piibooleannofalse 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_piifalse
descriptionstringnoup to 200 characters, for your convenienceCRM, 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:

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

EventWhenContent with include_pii: true
conversation.createdA conversation was createdyes (personal data)
message.createdA 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_requestedA handoff of the conversation to an operator was requested. data contains lead_id if the conversation has a leadyes
conversation.summary_readyThe conversation summary is readyyes

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

EventWhenContent with include_pii: true
lead.createdA lead was createdyes (the contact is personal data)
lead.updatedLead fields changed (the event contains the changed fields and the version)yes
lead.scoredThe lead was scored: score, level, reason, flagsyes
lead.status_changedThe lead status changedyes
lead.action_requestedAn 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 evidenceyes
task.createdA task was createdno
task.completedA task was closedno
contact.mergedContacts were mergedyes
crm.mode_changedThe tenant switched the CRM modeno

For more on CRM events and modes, see crm/01-modes.md and crm/04-agent-actions.md.

#Articles

EventWhen
article.readyThe article is ready (generated and passed review). If you publish yourself, pick up the article on this event
article.publishedPublication is confirmed (the address of the published page was received)
article.generation.failedArticle generation ended with an error

#Strategist

EventWhen
strategist.report.readyA new strategist report is ready
strategist.turn.completedA turn in the conversation with the strategist finished

#Service events

EventWhen
operation.completedAn asynchronous operation finished (succeeded, failed, canceled). data contains the operation envelope
api_key.expiringA key's validity is about to end: the event arrives 30 and 7 days before expiry
webhook.testA 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

HeaderValue
Content-Typeapplication/json
Sapport-Event-IdThe unique event identifier (the same in all retries of one delivery)
Sapport-Event-TypeThe event type, for example lead.created
Sapport-SignatureThe 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
  }
}
FieldTypeDescription
idstringThe event identifier (equal to Sapport-Event-Id)
typestringThe event type (equal to Sapport-Event-Type)
api_versionstringThe event schema version: 2026-10-01
created_atstringThe event time, UTC
livemodebooleantrue for production data; false for the webhook.test test event and sandbox data
dataobjectResource 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.

ConditionValue
EventsOnly lead.*, contact.*, conversation.*. The flag does not apply to other types; message.created never contains the message text
ScopeThe integration must have leads:read and/or conversations:read, depending on the types of the selected events
LogsTurning it on is recorded in the disclosure register; each such delivery goes into the personal data disclosure log (Cells and data)

Turn on include_pii only 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 country include_pii is 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
PartMeaning
tThe signing time (Unix seconds, UTC)
v1HMAC-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

  1. Get the raw body of the request (not re-serialized JSON).
  2. Parse Sapport-Signature: the t value and all v1 values.
  3. Compute HMAC_SHA256(secret, t + "." + body) in hex.
  4. Compare with each v1 in constant time. A match with any one is enough.
  5. Check freshness: |now − t| must not exceed the tolerance (5 minutes is recommended). Otherwise reject it as a replay.
  6. Check that Sapport-Event-Id has not been processed yet (protection against replays and duplicates).
  7. 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:

MeasureWhat 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 hoursA 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 comparisonNo leak through comparison timing
HTTPS only and a closed endpointThe signature protects integrity, not the confidentiality of the body

When one event is delivered again, Sapport-Event-Id does not change, while t and v1 are computed anew. The time tolerance applies to the current attempt.

#Delivery, retries, ordering

PropertyValue
Timeout10 seconds for a response
SuccessAny 2xx response received within 10 seconds
FailureAny other response, a timeout, a connection error, a 3xx response
RetriesThe 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 retryThe delivery becomes a "dead letter": it stays in the log and can be resent
Suspended exchangeIf 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)
OrderingNot guaranteed. Events may arrive out of the order in which they occurred
DuplicatesPossible (at-least-once delivery)

#Idempotent processing

Because of retries and reordering, the handler must be idempotent:

#Delivery log

GET /webhooks/{id}/deliveries (scope webhooks:manage, a cursor-paginated list: Conventions). Retained for 30 days.

FieldTypeDescription
idstringThe delivery identifier
event_idstringThe event identifier
statestringpending, delivered, failed, dead
next_attempt_atstring or nullThe time of the next attempt
created_atstringThe time the delivery was queued
attemptnumberThe attempt number (1 is the first, then retries)
status_codenumber or nullThe subscriber's HTTP response code; null if there was no response
duration_msnumberThe attempt duration in milliseconds
errorstring or nullA 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_atstring or nullThe 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).

  1. Request rotation of the subscription secret.
  2. 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 all v1 values and accepts the delivery if any one matches.
  3. Deploy the new secret on your side. Until you are done, verify the signature with both secrets (SECRETS in the examples above).
  4. After the window ends, the previous secret stops being valid and one v1 remains 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

CodeHTTPWhen
scope_missing403The key lacks webhooks:manage
validation_failed422The address is not https:443, points to an IP, or the event is unknown; include_pii: true for events outside lead.*, contact.*, conversation.*
scope_missing403include_pii: true without leads:read/conversations:read on the integration
pii_transfer_not_allowed403RU cell: include_pii: true with a hosting country of the receiving system outside Russia
not_found404The subscription is not found, or belongs to another tenant or another integration
invalid_state409More than 10 subscriptions per integration; a test event for an inactive subscription
feature_not_in_plan403The plan does not include webhooks

See the catalog.

#Notes