Walker Formulation Academy 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 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

  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 the platform's signature.
  4. 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.
  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
GET /webhooks/{id}/deliveriesDelivery logwebhooks:manage
GET /.well-known/jwks.jsonPublic keys used to verify delivery signatures. No authorizationnot required

#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/webhooks
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 "$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:

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
Webhook-Event-IdThe unique event identifier (the same in all retries of one delivery)
Webhook-Event-TypeThe event type, for example lead.created
Webhook-TimestampThe signing time in Unix seconds (UTC). Set anew on every attempt
Webhook-SignatureA 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
  }
}
FieldTypeDescription
idstringThe event identifier (equal to Webhook-Event-Id)
typestringThe event type (equal to Webhook-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. 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).

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 (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
HeaderMeaning
Webhook-Event-IdThe event identifier. It does not change across retries of one delivery
Webhook-TimestampThe signing time, Unix seconds, UTC. Set anew on every attempt
Webhook-SignatureA 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:

  1. Get the raw body of the request (not re-serialized JSON) and the three headers.
  2. Check freshness: |now − Webhook-Timestamp| must not exceed 300 seconds (5 minutes). Otherwise reject the delivery as a replay.
  3. From Webhook-Signature, take the protected header and the signature; pick the key from the JWKS set by kid. If that kid is not in your cache, re-fetch the set once.
  4. 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.
  5. Verify it with a JOSE library using the public key, allowing only EdDSA.
  6. Check that Webhook-Event-Id has not been processed yet (protection against 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.

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:

MeasureWhat 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 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
Verification with a JOSE library rather than manual string comparisonNo custom signature comparison or format parsing code
HTTPS only and a closed endpointThe signature protects integrity and the sender's authenticity, not the confidentiality of the body

When one event is delivered again, Webhook-Event-Id does not change, while Webhook-Timestamp and 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:

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

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)
Signing key unavailableA delivery is never sent unsigned: it is deferred for 15 minutes without consuming retries (the log shows signing_key_unavailable)
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.

#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
service_unavailable503GET /.well-known/jwks.json: the cell's signing key is temporarily unavailable (deliveries are deferred in the meantime)

See the catalog.

#Notes