Webhooks
A webhook is UYSOT POSTing JSON to your HTTPS endpoint whenever something happens — a lead
is created, its stage changes, a call ends. Instead of polling the Open API, you let the change
come to you.
On this page:
- How it works
- Configuration
- The signing secret
- The delivery request
- Verifying the signature (required)
- Payload structure
- Event catalogue — full examples
- Retries, dead events and the circuit breaker
- Idempotency and ordering
- Throughput and fairness
- Endpoint requirements
- FAQ
1. How it works
Three properties follow from that picture, and they explain almost every question below:
- An event is written in the same transaction as the change that caused it. A saved lead never loses its webhook, and a rolled-back change never emits one.
- Delivery is per installation (per connected company), not per event. If two of your
applications are installed by the same company and both subscribe to
LEAD_CREATED, one lead creation produces two deliveries — the sameeventId, differentX-Webhook-Installation-Id. - An event is only recorded if somebody is subscribed to it at that moment. Enabling an event later does not backfill what happened before.
Success is any 2xx. Everything else is a failure (section 8), and delivery is
at-least-once — the same event can arrive twice (section 9).
2. Configuration
Webhook configuration lives on the application — one application, one webhook config. The
application owner manages it in the CRM (app management → webhook config; the underlying calls
require the PERMISSION_OPEN_API_CONFIGURATION permission):
| Field | Meaning |
|---|---|
targetUrl | Where events are POSTed. HTTPS is mandatory, max 1024 chars |
events | The set of subscribed events (section 7). Must not be empty |
enabled | Delivery stops while this is false |
webhookSecret | Application-level HMAC key — returned in clear text only on creation or with regenerateSecret: true |
{
"targetUrl": "https://acme.example/uysot/webhook",
"events": ["LEAD_CREATED", "LEAD_STAGE_CHANGED", "CALL_COMPLETED"],
"enabled": true
}
For a PUBLIC (OAuth) application the config belongs to the application and applies to every
company that installed it — one targetUrl receives events from all of them. The payload's
companyId and the X-Webhook-Installation-Id header tell you which one. When a company
disconnects (the installation is revoked), its events stop being delivered.
Deleting the config, disabling it, or emptying events stops delivery; nothing is queued while
there is no active subscription.
3. The signing secret
Every secret is a 64-character lowercase hex string and is shown once. There are two levels, and the signer picks them in this order:
- Per installation — the key of one company's connection. This is what new connections use.
- Application level — the key from the webhook config, used as a fallback only for older connections that have no per-installation key yet.
| Application type | Where you get the signing secret |
|---|---|
PUBLIC (OAuth) | webhook_secret in the authorization_code token response — see OAuth. Never returned on refresh_token |
PRIVATE (PAT) | The webhook config response (webhookSecret), or the installation's regenerate-secret call in the CRM |
:::danger Store the secret per installation
For a public application, keep a map installationId → secret. A single application-level key
would mean one tenant's leaked key could sign every other tenant's payloads; that is exactly why
the key moved to the installation. Signature verification therefore starts with the
X-Webhook-Installation-Id header, not with anything inside the body.
:::
:::note Binding a secret to an installation id
The token response gives you the secret but not the numeric installationId — that number
first reaches you in the X-Webhook-Installation-Id header of a delivery. So store the secret
keyed by company at token-exchange time (read company.id from
GET /v1/open-api/token/info), and learn the installationId → company binding from the first
delivery: verify the signature against the not-yet-bound candidates, and once one matches, the
verified payload's companyId confirms which connection the header refers to. From then on the
lookup is a direct hit on the header.
:::
Rotation. Regenerating one installation's secret affects only that tenant; regenerating the application-level key affects only connections still on the fallback. Either way, signing switches to the new key immediately — accept both the old and the new secret for a short window while you roll over (section 8 explains why a rejected delivery is expensive).
4. The delivery request
POST /uysot/webhook HTTP/1.1
Host: acme.example
Content-Type: application/json
X-Webhook-Id: 7c1f4b2a-9d38-4f61-b0e2-5a8c3d17e904
X-Webhook-Installation-Id: 481
X-Webhook-Timestamp: 1766131200
X-Webhook-Signature: sha256=3f9a1c…e07b
{"eventId":"7c1f4b2a-…","eventType":"LEAD_CREATED", … }
| Header | Meaning |
|---|---|
X-Webhook-Id | The event UUID, identical to eventId in the body |
X-Webhook-Installation-Id | Which connection (company × application) this delivery belongs to — pick the verification key by this, and combine it with X-Webhook-Id as your idempotency key |
X-Webhook-Timestamp | When the signature was produced, epoch seconds |
X-Webhook-Signature | sha256= followed by the lowercase hex HMAC (section 5) |
No other authentication is sent: no basic auth, no bearer token, no custom secret header. The signature is the whole proof.
:::note Redirects are not followed
A 3xx response is not followed — targetUrl must be the final destination, otherwise the
delivery counts as a non-retryable failure.
:::
5. Verifying the signature (required)
signature = HMAC_SHA256(secret, "{X-Webhook-Timestamp}.{raw body}")
secretis the secret of the installation inX-Webhook-Installation-Id(section 3).- The key is the UTF-8 bytes of the secret string — do not hex-decode it.
- The result is lowercase hex, prefixed with
sha256=in the header. - The signature covers the raw body. The bytes we sign are exactly the bytes we send, so never parse and re-serialize the JSON before signing — whitespace and key order would change them. Take the raw string from your framework.
- Node.js (Express)
- Java / Kotlin
- Python (Flask)
const crypto = require("crypto");
// IMPORTANT: you need the raw body → express.raw() or express.json({ verify: ... })
app.post("/uysot/webhook", express.raw({ type: "application/json" }), (req, res) => {
const installationId = req.get("X-Webhook-Installation-Id");
const ts = req.get("X-Webhook-Timestamp");
const received = (req.get("X-Webhook-Signature") || "").replace(/^sha256=/, "");
const raw = req.body.toString("utf8");
// Your own store. On the very first delivery from a connection the id is unknown —
// fall back to trying the not-yet-bound secrets (see "Binding a secret to an installation id").
const secret = secretsByInstallation[installationId];
if (!secret) return res.status(401).end();
const expected = crypto
.createHmac("sha256", secret)
.update(`${ts}.${raw}`, "utf8")
.digest("hex");
const ok =
received.length === expected.length &&
crypto.timingSafeEqual(Buffer.from(received), Buffer.from(expected));
if (!ok) return res.status(401).end();
// Replay protection: reject very old timestamps (say, older than 5 minutes)
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return res.status(401).end();
enqueue(installationId, JSON.parse(raw)); // hand heavy work to a background queue
res.status(200).end(); // and answer FAST
});
fun verify(rawBody: String, timestamp: String, header: String, secret: String): Boolean {
val mac = Mac.getInstance("HmacSHA256")
mac.init(SecretKeySpec(secret.toByteArray(Charsets.UTF_8), "HmacSHA256"))
val expected = mac.doFinal("$timestamp.$rawBody".toByteArray(Charsets.UTF_8))
.joinToString("") { "%02x".format(it) }
val received = header.removePrefix("sha256=")
return MessageDigest.isEqual(expected.toByteArray(), received.toByteArray())
}
import hmac, hashlib, time
@app.post("/uysot/webhook")
def webhook():
raw = request.get_data() # bytes — the raw body
installation_id = request.headers.get("X-Webhook-Installation-Id", "")
ts = request.headers.get("X-Webhook-Timestamp", "")
received = request.headers.get("X-Webhook-Signature", "").removeprefix("sha256=")
secret = SECRETS.get(installation_id) # your own store; unknown id on first delivery
if not secret:
return "", 401
expected = hmac.new(
secret.encode("utf-8"),
f"{ts}.".encode("utf-8") + raw,
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(expected, received):
return "", 401
if abs(time.time() - int(ts)) > 300:
return "", 401
enqueue(installation_id, request.get_json())
return "", 200
:::warning Never trust an unverified payload
Your endpoint is exposed to the internet — without signature verification anyone can send you a
fake "lead created". Always compare with a constant-time function (timingSafeEqual,
compare_digest), and never trust companyId from the body before the signature checks out.
:::
6. Payload structure
All events share one envelope. Event-specific blocks appear only for their own event: a null
block is omitted from the body entirely, so "stageChange" never shows up as null.
| Field | Type | Meaning |
|---|---|---|
eventId | string (UUID) | Event identifier — same value as X-Webhook-Id |
eventType | string | Event name (section 7) |
occurredAt | number | When the event was recorded, epoch seconds |
companyId | number (int) | Which company (tenant) the event belongs to |
lead | object | Lead snapshot — present on every LEAD_* event, absent on CALL_COMPLETED |
stageChange | object | LEAD_STAGE_CHANGED only |
assignment | object | LEAD_ASSIGNED only |
mergedLeadIds | number[] | LEAD_MERGED only |
propertyChanges | object[] | LEAD_PROPERTIES_UPDATED only |
call | object | CALL_COMPLETED only |
6.1 lead — the lead snapshot
Values are a snapshot at event time, taken from the saved lead.
| Field | Type | Note |
|---|---|---|
id | number (long) | Lead id |
name | string | Lead name |
statusId / statusName | number | null / string | null | Current pipeline stage |
pipeId / pipeName | number | null / string | null | Pipeline the stage belongs to |
responsibleId / responsibleName | number | null / string | null | Assigned employee |
balance | number | Lead balance |
currency | string | null | ISO currency code of the balance |
phone | string | null | First phone of the main contact |
attributions | object[] | Channel sources — channelId, source, channelValue, attachedTimestamp. Empty when the lead has no channel |
uuid | string | null | Lead uuid |
createdTimestamp / updatedTimestamp | number | null | epoch seconds |
attributions[].source is a LeadChannelSource name — INSTAGRAM, TELEGRAM, TELEGRAM_BOT,
WHATSAPP, WABA, FACEBOOK, FACEBOOK_FORM, VK, VIBER, AVITO, TIKTOK, MARQUIZ,
WEBSITE, WEB_FORM, AMO_CRM, ONLINE_PBX, UTEL, YEASTAR, SIPUNI, ASTERISK,
MOI_ZVONKI, OPEN_API, MENING_UYIM, COPY_LEAD_TRIGGER, EMPLOYEE, OFFLINE_MEET,
UNKNOWN. Treat an unknown value as UNKNOWN rather than failing — the list grows.
7. Event catalogue — full examples
eventType | Group | Fires when | Extra block |
|---|---|---|---|
LEAD_CREATED | LEAD | A lead is created — through the UI, the Open API, an Excel import, or a copy-lead trigger | — |
LEAD_ASSIGNED | LEAD | The responsible employee changes (including bulk reassign) | assignment |
LEAD_STAGE_CHANGED | LEAD | The lead moves to another pipeline stage | stageChange |
LEAD_PROPERTIES_UPDATED | LEAD | Name, balance, a contact, or a custom field changes | propertyChanges |
LEAD_MERGED | LEAD | Leads are merged into one | mergedLeadIds |
LEAD_DELETED | LEAD | A lead is deleted | — |
CALL_COMPLETED | CALL | A conversation (call or meeting) is finished and journaled | call |
Events are organised into groups (LEAD, LEAD_NOTE, LEAD_TASK, CONTRACT,
CONTRACT_PAYMENT, CALL). Today only LEAD and CALL publish events; the other groups are
reserved for events still to come.
:::tip One change can produce several events
Saving a lead where the stage, the assignee and the name all changed emits three events —
LEAD_STAGE_CHANGED, LEAD_ASSIGNED and LEAD_PROPERTIES_UPDATED — each with its own eventId.
They are independent deliveries and can arrive in any order.
:::
7.1 LEAD_CREATED
The full envelope with the lead snapshot and no extra block. This is the shape every LEAD_*
example below builds on.
{
"eventId": "7c1f4b2a-9d38-4f61-b0e2-5a8c3d17e904",
"eventType": "LEAD_CREATED",
"occurredAt": 1766131200,
"companyId": 34,
"lead": {
"id": 99436,
"name": "Ali Valiyev",
"statusId": 11,
"statusName": "New contact",
"pipeId": 4,
"pipeName": "Sales",
"responsibleId": 252,
"responsibleName": "Dilshod Karimov",
"balance": 0,
"currency": "UZS",
"phone": "+998901234567",
"attributions": [
{
"channelId": 77,
"source": "INSTAGRAM",
"channelValue": "uysot_official",
"attachedTimestamp": 1766131199
}
],
"uuid": "1f0c8a52-6b3d-4a71-9c2e-0d5b8e4a7c31",
"createdTimestamp": 1766131199,
"updatedTimestamp": 1766131199
}
}
7.2 LEAD_ASSIGNED
lead.responsibleId already holds the new assignee; assignment carries both sides. Any of
the four fields is null when the lead had (or now has) nobody responsible.
{
"eventId": "a1b2c3d4-5e6f-4a7b-8c9d-0e1f2a3b4c5d",
"eventType": "LEAD_ASSIGNED",
"occurredAt": 1766131500,
"companyId": 34,
"lead": {
"id": 99436,
"name": "Ali Valiyev",
"statusId": 11,
"statusName": "New contact",
"pipeId": 4,
"pipeName": "Sales",
"responsibleId": 261,
"responsibleName": "Nodira Yusupova",
"balance": 0,
"currency": "UZS",
"phone": "+998901234567",
"attributions": [],
"uuid": "1f0c8a52-6b3d-4a71-9c2e-0d5b8e4a7c31",
"createdTimestamp": 1766131199,
"updatedTimestamp": 1766131500
},
"assignment": {
"fromResponsibleId": 252,
"fromResponsibleName": "Dilshod Karimov",
"toResponsibleId": 261,
"toResponsibleName": "Nodira Yusupova"
}
}
7.3 LEAD_STAGE_CHANGED
lead.statusId is the new stage; stageChange carries old → new. A move between pipelines
shows up here too — compare lead.pipeId if you track pipelines separately.
{
"eventId": "2e4f6a80-1b3c-4d5e-8f90-a1b2c3d4e5f6",
"eventType": "LEAD_STAGE_CHANGED",
"occurredAt": 1766132000,
"companyId": 34,
"lead": {
"id": 99436,
"name": "Ali Valiyev",
"statusId": 12,
"statusName": "Negotiation",
"pipeId": 4,
"pipeName": "Sales",
"responsibleId": 261,
"responsibleName": "Nodira Yusupova",
"balance": 0,
"currency": "UZS",
"phone": "+998901234567",
"attributions": [],
"uuid": "1f0c8a52-6b3d-4a71-9c2e-0d5b8e4a7c31",
"createdTimestamp": 1766131199,
"updatedTimestamp": 1766132000
},
"stageChange": {
"fromStatusId": 11,
"fromStatusName": "New contact",
"toStatusId": 12,
"toStatusName": "Negotiation"
}
}
7.4 LEAD_PROPERTIES_UPDATED
One entry per property that actually changed. Stage and assignee changes are not here — they have their own events.
type | Extra id | from / to format |
|---|---|---|
NAME | — | The lead name as text |
BALANCE | — | "<amount> <currency>", trailing zeros stripped — e.g. "1500.5 USD" |
CONTACT | contactId | "<name> [<phone>, <phone>]" — phones sorted |
CUSTOM_FIELD | fieldId | The field's rendered value: an address is the full address, a multi-select is values joined by ", ", a file field is file ids joined by ", " |
from: null means the property was added; to: null means it was removed (a contact detached from
the lead, a custom field cleared). Only lead-level custom fields appear; contact fields are
reported through the CONTACT entry of that contact.
{
"eventId": "9f8e7d6c-5b4a-4938-8271-6a5b4c3d2e1f",
"eventType": "LEAD_PROPERTIES_UPDATED",
"occurredAt": 1766132400,
"companyId": 34,
"lead": {
"id": 99436,
"name": "Ali Valiyev (family)",
"statusId": 12,
"statusName": "Negotiation",
"pipeId": 4,
"pipeName": "Sales",
"responsibleId": 261,
"responsibleName": "Nodira Yusupova",
"balance": 1500.5,
"currency": "USD",
"phone": "+998901234567",
"attributions": [],
"uuid": "1f0c8a52-6b3d-4a71-9c2e-0d5b8e4a7c31",
"createdTimestamp": 1766131199,
"updatedTimestamp": 1766132400
},
"propertyChanges": [
{ "type": "NAME", "from": "Ali Valiyev", "to": "Ali Valiyev (family)" },
{ "type": "BALANCE", "from": "0 UZS", "to": "1500.5 USD" },
{
"type": "CONTACT",
"contactId": 8814,
"from": "Ali Valiyev [+998901234567]",
"to": "Ali Valiyev [+998901234567, +998939876543]"
},
{ "type": "CONTACT", "contactId": 8815, "to": "Zarina Valiyeva [+998977778899]" },
{ "type": "CUSTOM_FIELD", "fieldId": 51, "from": "2 rooms", "to": "3 rooms" },
{ "type": "CUSTOM_FIELD", "fieldId": 64, "from": "Chilonzor, Tashkent" }
]
}
In that example contact 8815 was added (no from) and custom field 64 was cleared (no to).
7.5 LEAD_MERGED
lead is the surviving lead; mergedLeadIds are the ids that were merged into it and no
longer exist as separate leads. The surviving id is never repeated in that list — update your own
records to point at lead.id.
{
"eventId": "b7c6d5e4-f3a2-4b19-9c8d-7e6f5a4b3c2d",
"eventType": "LEAD_MERGED",
"occurredAt": 1766133000,
"companyId": 34,
"lead": {
"id": 99436,
"name": "Ali Valiyev (family)",
"statusId": 12,
"statusName": "Negotiation",
"pipeId": 4,
"pipeName": "Sales",
"responsibleId": 261,
"responsibleName": "Nodira Yusupova",
"balance": 1500.5,
"currency": "USD",
"phone": "+998901234567",
"attributions": [
{
"channelId": 77,
"source": "INSTAGRAM",
"channelValue": "uysot_official",
"attachedTimestamp": 1766131199
},
{
"channelId": 91,
"source": "TELEGRAM_BOT",
"channelValue": "uysot_sales_bot",
"attachedTimestamp": 1766120000
}
],
"uuid": "1f0c8a52-6b3d-4a71-9c2e-0d5b8e4a7c31",
"createdTimestamp": 1766131199,
"updatedTimestamp": 1766133000
},
"mergedLeadIds": [99401, 99377]
}
7.6 LEAD_DELETED
The snapshot is the lead as it was when it was deleted — your last chance to reconcile.
{
"eventId": "c3d2e1f0-a9b8-4756-8433-2f1e0d9c8b7a",
"eventType": "LEAD_DELETED",
"occurredAt": 1766133600,
"companyId": 34,
"lead": {
"id": 99401,
"name": "Test lead",
"statusId": 15,
"statusName": "Refused",
"pipeId": 4,
"pipeName": "Sales",
"responsibleId": 252,
"responsibleName": "Dilshod Karimov",
"balance": 0,
"currency": "UZS",
"phone": null,
"attributions": [],
"uuid": "8a2b6c14-9d0e-4f31-a5b7-2c3d4e5f6a7b",
"createdTimestamp": 1765900000,
"updatedTimestamp": 1766133590
}
}
7.7 CALL_COMPLETED
{
"eventId": "b2d4e6f8-1a3c-5e7f-9b0d-2c4e6a8f0b1d",
"eventType": "CALL_COMPLETED",
"occurredAt": 1766134800,
"companyId": 34,
"call": {
"callId": "utel-88213-9021",
"provider": "UTEL",
"conversationKind": "CALL",
"direction": "INBOUND",
"answered": true,
"startedAt": 1766134730,
"endedAt": 1766134795,
"durationSec": 65,
"talkTimeSec": 51,
"hangupCause": "ANSWER",
"clientPhone": "+998901234567",
"operatorExtension": "204",
"employeeId": 252,
"employeeName": "Dilshod Karimov",
"contactId": 8814,
"contactName": "Ali Valiyev",
"leadIds": [99436],
"record": {
"fileId": 55102,
"url": "https://files.uysot.uz/records/2026/08/utel-88213-9021.mp3?X-Amz-Signature=…",
"expiresAt": 1766566800,
"durationSec": 51
}
}
}
| Field | Type | Note |
|---|---|---|
callId | string | Conversation uuid in the call journal — stable, use it for your own dedup |
provider | string | null | Telephony source: UTEL, SIPUNI, ONLINE_PBX, YEASTAR, ASTERISK, MOI_ZVONKI, OPEN_API, … |
conversationKind | string | CALL | OFFLINE_MEET | ONLINE_MEET |
direction | string | INBOUND | OUTBOUND (always INBOUND for meetings) |
answered | boolean | true when there was real talk time or the hangup cause is ANSWER — a missed call is delivered too, with answered: false |
startedAt / endedAt | number | null | epoch seconds |
durationSec | number | null | Total conversation length |
talkTimeSec | number | null | Net talk time |
hangupCause | string | null | Provider's hangup cause, e.g. ANSWER, NO_ANSWER, BUSY |
clientPhone | string | null | The contact's number if known, otherwise the caller (inbound) or dialled (outbound) number |
operatorExtension | string | null | Operator's internal extension |
employeeId / employeeName | number | null / string | null | The employee on the call |
contactId / contactName | number | null / string | null | The matched CRM contact |
leadIds | number[] | Leads this conversation was journaled on — can be empty |
record | object | absent | Recording. Absent when no audio was captured or the link could not be signed |
record.url | string | Presigned, expiring download link |
record.expiresAt | number | epoch seconds; the link's TTL is up to 5 days |
record.durationSec | number | null | Audio length when known — often absent for recordings pulled from the provider |
:::caution Things to know about CALL_COMPLETED
- There is no
leadblock. One conversation can be journaled on several leads, and there is only one webhook. The ids are incall.leadIds; fetch details from the Open API if you need them. - Meetings land here too. Despite the name, everything that reaches the call journal is
delivered; filter on
conversationKindif you only care about calls. On meetings the call-shaped fields are loose —directionis alwaysINBOUND,clientPhoneandoperatorExtensionmay be empty. record.urlexpires. Download and store the file yourself; do not persist the link.- One conversation, one webhook. Even though several internal paths can finish a call (no
recording, recording attached later, download failed) and a provider may report the same call
twice, duplicates are suppressed for 24 hours per
callId. Across that boundary you may see a repeat — your own dedup oncallIdcloses the gap. :::
8. Retries, dead events and the circuit breaker
| Your response | What UYSOT does |
|---|---|
2xx | Success — the row leaves the queue |
408, 429, 5xx, timeout, connection error | Retried with backoff |
Any other 4xx (400, 401, 403, 404, 410, …) | No retry — the event is marked dead |
3xx | Not followed → counted as a failed delivery |
- Attempts: the initial delivery plus up to 3 retries, backing off 30 seconds → 5 minutes → 30 minutes. After that the event is dead and is never sent again.
- Timeouts: 3 s to connect, 10 s to respond. Answer fast — queue the heavy work and return
200immediately. - Circuit breaker per installation. Over a sliding window of the last 20 deliveries, if half
of them fail with retryable errors, that connection's deliveries pause for 30 seconds and
then probe again. A blocked delivery is treated as retryable, so it is retried, not dropped —
and the breaker is scoped to one installation: your broken endpoint never affects another
tenant's, and vice versa. Non-retryable
4xxresponses do not open the breaker.
Some conditions are dead on arrival — no retry, no attempt at all:
- the webhook config is missing or
enabled: false; - the installation was revoked (the company disconnected your app);
targetUrlis not HTTPS, or resolves to a private/internal address at send time;- the signing secret cannot be resolved.
Dead deliveries raise an internal alarm on our side, and every attempt is journaled with its status code, error and duration (that journal is kept for 7 days) — so support can tell you exactly what your endpoint answered.
:::tip Returning 401 is expensive
Rejecting a bad signature with 401 is non-retryable, so the event is lost for good. While
rotating a secret, accept both the old and the new one for a short window. If you must reject
temporarily — for example your secret store is unavailable — answer 503, not 401, so the
delivery is retried.
:::
9. Idempotency and ordering
- Deduplicate on
(X-Webhook-Installation-Id, eventId). Retries, network hiccups or a lost200mean the same event can arrive twice. Keep seen pairs for, say, 24 hours and silently drop duplicates with a200. The pair matters, noteventIdalone: one logical event is fanned out to every subscribed installation with the sameeventId. - Ordering is not guaranteed. Retries and per-company fairness (section 10) mean
LEAD_STAGE_CHANGEDcan arrive beforeLEAD_CREATED. Sort byoccurredAt, or confirm the current state through the Open API. - The payload is a snapshot at event time, and
occurredAtis when the event was recorded. When you need the newest state, read the Open API. - Nothing is replayed automatically. After a dead event or an outage, backfill from the Open
API (for example
POST /v1/open-api/lead/filterbyupdatedTimestamp).
10. Throughput and fairness
Delivery is scheduled, not instantaneous — expect a lag of about a second under normal load, more while a burst drains:
| Limit | Value |
|---|---|
| Dispatch cycle | ~1 s |
| In-flight deliveries per company | 20 |
| Deliveries claimed per company per cycle | 10 |
| Deliveries claimed globally per cycle | 100 |
These caps are what keep one busy tenant (a bulk import, a mass reassign) from starving the others. A slow endpoint mostly slows down its own queue: with a 10-second timeout and 20 in-flight slots, an endpoint that always times out drains far more slowly than one answering in 50 ms.
11. Endpoint requirements
- HTTPS only.
http://targets are rejected. - Publicly resolvable.
localhost,*.local,*.internal,*.localdomainand private ranges (10.x,172.16–31.x,192.168.x,127.x,169.254.x,0.0.0.0,100.64/10) are blocked as SSRF protection — re-checked at send time, not just when the config is saved, so a DNS record that later points inward stops delivery. - No redirects —
3xxis not followed. - Answer in under 10 seconds, with any
2xx. Body content is ignored. - Accept
Content-Type: application/jsonand read the raw body for verification. - The HMAC signature is the only authentication; no extra headers are sent.
- Be ready for the same event twice, and for unknown enum values and new payload fields — added fields are not a breaking change.
12. FAQ
My signature never matches.
Nine times out of ten the body was re-serialized instead of used raw. Next most common: using
the application-level secret when the delivery is signed with the installation's own key — pick the
key by X-Webhook-Installation-Id (section 3). Then: hex-decoding the secret (don't — the string
itself is the key), and forgetting the timestamp and the dot in "{timestamp}.{body}".
I'm not receiving events.
Check that (1) the config is enabled; (2) the event is in events; (3) targetUrl is HTTPS and
publicly resolvable; (4) the company's installation is not revoked; (5) earlier failures have not
opened the circuit breaker; (6) the event did not go dead after 3 retries.
I enabled a new event — where is the history? There is none. Events are only recorded while a subscription exists. Backfill from the Open API.
Can a dead event be replayed? There is no automatic replay, and no replay endpoint. Reconcile through the Open API.
I received the same event twice.
Expected — at-least-once delivery. Deduplicate on (installationId, eventId) (section 9).
Why did one lead update send me three webhooks?
Because three different things changed. Stage, assignee and plain properties are separate events
with separate eventIds (section 7).
What happens when I rotate a secret? Signing switches to the new key immediately for that installation. Verify against both the old and the new secret for a short period.
Several companies use my app — how do I tell them apart?
By X-Webhook-Installation-Id (which also selects the verification key) and companyId in the
payload. One targetUrl receives events from every connected company.
Should I use webhooks or polling? Polling works but eats your rate limit (60 requests/minute per token). Webhooks plus an occasional reconciliation pass against the Open API is the most robust combination.