Skip to main content

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:


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 same eventId, different X-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):

FieldMeaning
targetUrlWhere events are POSTed. HTTPS is mandatory, max 1024 chars
eventsThe set of subscribed events (section 7). Must not be empty
enabledDelivery stops while this is false
webhookSecretApplication-level HMAC key — returned in clear text only on creation or with regenerateSecret: true
Webhook config
{
"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:

  1. Per installation — the key of one company's connection. This is what new connections use.
  2. Application level — the key from the webhook config, used as a fallback only for older connections that have no per-installation key yet.
Application typeWhere 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", … }
HeaderMeaning
X-Webhook-IdThe event UUID, identical to eventId in the body
X-Webhook-Installation-IdWhich 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-TimestampWhen the signature was produced, epoch seconds
X-Webhook-Signaturesha256= 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}")
  • secret is the secret of the installation in X-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.
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
});

:::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.

FieldTypeMeaning
eventIdstring (UUID)Event identifier — same value as X-Webhook-Id
eventTypestringEvent name (section 7)
occurredAtnumberWhen the event was recorded, epoch seconds
companyIdnumber (int)Which company (tenant) the event belongs to
leadobjectLead snapshot — present on every LEAD_* event, absent on CALL_COMPLETED
stageChangeobjectLEAD_STAGE_CHANGED only
assignmentobjectLEAD_ASSIGNED only
mergedLeadIdsnumber[]LEAD_MERGED only
propertyChangesobject[]LEAD_PROPERTIES_UPDATED only
callobjectCALL_COMPLETED only

6.1 lead — the lead snapshot​

Values are a snapshot at event time, taken from the saved lead.

FieldTypeNote
idnumber (long)Lead id
namestringLead name
statusId / statusNamenumber | null / string | nullCurrent pipeline stage
pipeId / pipeNamenumber | null / string | nullPipeline the stage belongs to
responsibleId / responsibleNamenumber | null / string | nullAssigned employee
balancenumberLead balance
currencystring | nullISO currency code of the balance
phonestring | nullFirst phone of the main contact
attributionsobject[]Channel sources — channelId, source, channelValue, attachedTimestamp. Empty when the lead has no channel
uuidstring | nullLead uuid
createdTimestamp / updatedTimestampnumber | nullepoch 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​

eventTypeGroupFires whenExtra block
LEAD_CREATEDLEADA lead is created — through the UI, the Open API, an Excel import, or a copy-lead trigger—
LEAD_ASSIGNEDLEADThe responsible employee changes (including bulk reassign)assignment
LEAD_STAGE_CHANGEDLEADThe lead moves to another pipeline stagestageChange
LEAD_PROPERTIES_UPDATEDLEADName, balance, a contact, or a custom field changespropertyChanges
LEAD_MERGEDLEADLeads are merged into onemergedLeadIds
LEAD_DELETEDLEADA lead is deleted—
CALL_COMPLETEDCALLA conversation (call or meeting) is finished and journaledcall

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.

LEAD_CREATED
{
"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.

LEAD_ASSIGNED
{
"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.

LEAD_STAGE_CHANGED
{
"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.

typeExtra idfrom / to format
NAME—The lead name as text
BALANCE—"<amount> <currency>", trailing zeros stripped — e.g. "1500.5 USD"
CONTACTcontactId"<name> [<phone>, <phone>]" — phones sorted
CUSTOM_FIELDfieldIdThe 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.

LEAD_PROPERTIES_UPDATED
{
"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.

LEAD_MERGED
{
"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.

LEAD_DELETED
{
"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​

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
}
}
}
FieldTypeNote
callIdstringConversation uuid in the call journal — stable, use it for your own dedup
providerstring | nullTelephony source: UTEL, SIPUNI, ONLINE_PBX, YEASTAR, ASTERISK, MOI_ZVONKI, OPEN_API, …
conversationKindstringCALL | OFFLINE_MEET | ONLINE_MEET
directionstringINBOUND | OUTBOUND (always INBOUND for meetings)
answeredbooleantrue when there was real talk time or the hangup cause is ANSWER — a missed call is delivered too, with answered: false
startedAt / endedAtnumber | nullepoch seconds
durationSecnumber | nullTotal conversation length
talkTimeSecnumber | nullNet talk time
hangupCausestring | nullProvider's hangup cause, e.g. ANSWER, NO_ANSWER, BUSY
clientPhonestring | nullThe contact's number if known, otherwise the caller (inbound) or dialled (outbound) number
operatorExtensionstring | nullOperator's internal extension
employeeId / employeeNamenumber | null / string | nullThe employee on the call
contactId / contactNamenumber | null / string | nullThe matched CRM contact
leadIdsnumber[]Leads this conversation was journaled on — can be empty
recordobject | absentRecording. Absent when no audio was captured or the link could not be signed
record.urlstringPresigned, expiring download link
record.expiresAtnumberepoch seconds; the link's TTL is up to 5 days
record.durationSecnumber | nullAudio length when known — often absent for recordings pulled from the provider

:::caution Things to know about CALL_COMPLETED

  • There is no lead block. One conversation can be journaled on several leads, and there is only one webhook. The ids are in call.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 conversationKind if you only care about calls. On meetings the call-shaped fields are loose — direction is always INBOUND, clientPhone and operatorExtension may be empty.
  • record.url expires. 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 on callId closes the gap. :::

8. Retries, dead events and the circuit breaker​

Your responseWhat UYSOT does
2xxSuccess — the row leaves the queue
408, 429, 5xx, timeout, connection errorRetried with backoff
Any other 4xx (400, 401, 403, 404, 410, …)No retry — the event is marked dead
3xxNot 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 200 immediately.
  • 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 4xx responses 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);
  • targetUrl is 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 lost 200 mean the same event can arrive twice. Keep seen pairs for, say, 24 hours and silently drop duplicates with a 200. The pair matters, not eventId alone: one logical event is fanned out to every subscribed installation with the same eventId.
  • Ordering is not guaranteed. Retries and per-company fairness (section 10) mean LEAD_STAGE_CHANGED can arrive before LEAD_CREATED. Sort by occurredAt, or confirm the current state through the Open API.
  • The payload is a snapshot at event time, and occurredAt is 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/filter by updatedTimestamp).

10. Throughput and fairness​

Delivery is scheduled, not instantaneous — expect a lag of about a second under normal load, more while a burst drains:

LimitValue
Dispatch cycle~1 s
In-flight deliveries per company20
Deliveries claimed per company per cycle10
Deliveries claimed globally per cycle100

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, *.localdomain and 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 — 3xx is not followed.
  • Answer in under 10 seconds, with any 2xx. Body content is ignored.
  • Accept Content-Type: application/json and 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.