App webhooks
An installed app receives the workspace's events at the webhook URL in its manifest, as v3 envelopes signed with a key that belongs to that install alone. This page is the receiving side; the catalog of events, with every field of every event and an example, is in the app at /developers/events — that page is the canonical, always-current list (each event also has a page in the API reference, under App events).
Subscribing your own URL to your own workspace's events, without an app? That is an
outbound webhook — same pipeline, a key of its own, v1/v2/v3 bodies.
Your app's subscription
You never create it. When an admin allows your app, KudosCRM creates one subscription for the install,
from your manifest's webhook: the URL, with {install_id} replaced by the install's inst_… id,
and the events you listed — each of them only if the admin granted the read scope it needs
(contact.* needs contacts:read, deal.* needs deals:read, …; integration.* needs none).
An event your manifest lists that is not delivered yet joins on the next release or re-consent.
The subscription is the app's, not the workspace's: an admin cannot edit, retarget or re-sign it
(422 managed_subscription), and it is v3 only. It follows the install — paused while the app is
paused, retired when it is uninstalled.
Two rules decide what reaches you:
- Your own writes never come back. An event your app caused (through its token or its own
events) is not sent to it.
origintells you who caused everything else: a member (user), an automation, another app (app: its install'sinst_…id, with its key inapp), or the system (id: null). Anapporigin withoutappcame from an integration with no install behind it — a legacy app credential, or an inbound source an admin registered — and itsidis that source's uuid, not aninst_…id. - Only what the grant covers. A related record your install may not read arrives as its id
alone — an activity's
user, a task'scompleted_byand a meeting'suserneedusers:read, a ticket'srequesterneedscontacts:read. An email activity needsemails:read. A narrowed grant takes effect at send time: a delivery built before it is skipped, never sent.
The envelope
{
"event_id": "evt_4d88894b1eae8347dea3c548",
"type": "deal.stage_changed",
"occurred_at": "2026-10-08T10:42:18Z",
"tenant_id": "3f6a1c2e-8b4d-4e7f-9a10-5c2d8e7f6a31",
"install_id": "inst_7Hq2XcRm4P",
"payload_version": 2,
"origin": {
"kind": "user",
"id": "5e2b9d14-7c3a-4f86-b1d2-0a9e8c7b6d52"
},
"data": {
"deal": {
"id": "9c3a7e5b-6d4f-4a81-aeb2-3f4a5b6c7d86",
"title": "Brightline — 12 seats",
"value": 4800.0,
"currency": "USD",
"status": "open",
"pipeline_id": "a14b8f6c-7e5a-4b92-8fc3-4a5b6c7d8e97",
"stage": {
"id": "c36d0b8e-9a7c-4db4-a1e5-6c7d8e9fa0b9",
"name": "Proposal sent"
},
"owner_id": "5e2b9d14-7c3a-4f86-b1d2-0a9e8c7b6d52",
"contact_id": "7a1e5c39-4b2d-4e6f-8c90-1d2e3f4a5b64",
"company_id": "8b2f6d4a-5c3e-4f70-9da1-2e3f4a5b6c75",
"expected_close_date": "2026-10-31",
"created_at": "2026-10-01T09:15:00Z",
"updated_at": "2026-10-08T10:42:18Z"
},
"from_stage": {
"id": "b25c9a7d-8f6b-4ca3-90d4-5b6c7d8e9fa8",
"name": "Demo scheduled",
"stage_type": "open"
},
"to_stage": {
"id": "c36d0b8e-9a7c-4db4-a1e5-6c7d8e9fa0b9",
"name": "Proposal sent",
"stage_type": "open"
}
}
}
| Field | |
|---|---|
event_id | evt_ + 24 hex. The same on every retry and every replay — dedupe on it. |
type | The event. |
occurred_at | When it happened, UTC. |
tenant_id | The workspace (uuid). |
install_id | Your install — the one the token response named. |
payload_version | The version of data (2 for every event today). |
origin | Who caused it: {kind, id, app?}. |
data | The record under its object key, with exactly its documented fields (uuids only), plus the event's extras — from_stage/to_stage on deal.stage_changed, from_status/to_status on lead.stage_changed, breach on ticket.breached_sla. |
This example is generated: it is the envelope KudosCRM's serializer builds for a fixed
deal.stage_changed, checked on every build.
Headers
Content-Type: application/json
User-Agent: KudosCRM-Webhooks/1.0
X-Kudos-Signature: t=1791456139,v1=769793325260ba7e1e61fcb8432d4cd20c55a964afd568baae11f5aa7b080d2d
X-Kudos-Event: deal.stage_changed
X-Kudos-Delivery: evt_4d88894b1eae8347dea3c548
X-Kudos-Delivery-Attempt: 1
webhook-id: evt_4d88894b1eae8347dea3c548
webhook-timestamp: 1791456139
webhook-signature: v1,sYxoDmpFjAjuS8i1ytSUWVZxxwzSwPkzOdp8NJzL0dI=
webhook-id, webhook-timestamp and webhook-signature are Standard Webhooks,
so an off-the-shelf verifier works. X-Kudos-Signature carries the same key in the Stripe-style
scheme other KudosCRM webhooks use. Verify one of the two — you do not need both.
The signing key
Every install has its own key, derived from your app's signing secret and the install's id:
"whsec_" + Base64.strict_encode64(HMAC-SHA256(key = app.signing_secret exactly as issued incl. "whsec_",
data = "kudos-install-v1:" + install.public_id))
So you store one secret per app, not one per install, and a key derived for one install never verifies another install's deliveries. With this example app secret (yours is in the developer console):
app signing secret whsec_eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHg=
install_id inst_7Hq2XcRm4P
derived whsec_hTs4TYNIzadM4mtc4jrruSevitc2/mCwwFdRSKv4CqU=
When you roll the app's signing secret with a grace period, deliveries are signed with both keys —
the one derived from the new secret and the one from the previous — so webhook-signature carries two
signatures, space-separated (and X-Kudos-Signature two v1=). Accept the delivery when any one
matches.
Verify a delivery
Verify the raw body — the bytes you received, before any JSON parsing — and reject a timestamp
more than 5 minutes from your clock. Each snippet needs only its language's own libraries (Node 16+,
Python 3.10+, Ruby 3.0+; on Ruby 3.4+ under Bundler, add gem "base64" unless your app already has it,
as Rails apps do) and is checked against what KudosCRM actually sends.
import crypto from "node:crypto";
// The key KudosCRM signs this install's deliveries with: your app's signing
// secret (exactly as issued, "whsec_…") and the install's inst_… id.
export function installSigningKey(appSigningSecret, installId) {
const digest = crypto.createHmac("sha256", appSigningSecret)
.update(`kudos-install-v1:${installId}`)
.digest("base64");
return `whsec_${digest}`;
}
// Standard Webhooks: HMAC-SHA256 over "<webhook-id>.<webhook-timestamp>.<raw body>",
// keyed by the base64-decoded bytes after "whsec_". Any one signature may match.
// rawBody is the body exactly as received: a Buffer (express.raw()) or a string.
export function verifyDelivery({ rawBody, headers, appSigningSecret, installId,
now = Math.floor(Date.now() / 1000), toleranceS = 300 }) {
const id = headers["webhook-id"];
const timestamp = Number(headers["webhook-timestamp"]);
if (!id || !Number.isFinite(timestamp) || Math.abs(now - timestamp) > toleranceS) return false;
const key = Buffer.from(installSigningKey(appSigningSecret, installId).slice("whsec_".length), "base64");
const expected = crypto.createHmac("sha256", key).update(`${id}.${timestamp}.`).update(rawBody).digest();
return String(headers["webhook-signature"] || "").split(" ").some((entry) => {
const [version, signature] = entry.split(",", 2);
const given = Buffer.from(signature || "", "base64");
return version === "v1" && given.length === expected.length && crypto.timingSafeEqual(given, expected);
});
}
Using X-Kudos-Signature instead: it is t=<unix>,v1=<hex>[,v1=<hex>], the hex HMAC-SHA256 of
"<t>.<raw body>" keyed by the derived key string exactly as computed, whsec_ included — the
same scheme as the webhook signing guide, with the derived key as the secret.
Answer, retries and dead letters
Answer 2xx within 8 seconds (4 to connect) and do slow work afterwards. Redirects are never
followed.
| Your answer | What KudosCRM does |
|---|---|
2xx | Delivered. |
408, 429, 5xx, a timeout or a network error | Retried — 6 attempts in all, waits of about 30 s, 2 m, 10 m, 30 m and 1 h, each ±25 %. A Retry-After you send is honoured, up to 2 hours. |
any other 4xx (or a 3xx) | Your verdict on this payload: dead-lettered at once, no retry. |
410 Gone | Dead-lettered, and the install's subscription stops. |
Ten dead letters in a row stop the subscription the same way. The admin then sees Deliveries stopped after repeated failures on Connected apps; Retry turns it back on and resends the dead letters, oldest first — only those your app's current grant still covers.
The review ping
Besides the events you list, one more type reaches your webhook: webhook.ping. Review check 3 sends
it when your team runs the automated checks on the app's Test tab, to the checked version's webhook
URL with {install_id} filled in from a real install — the newest one in your home or test workspaces.
It is a v3 envelope like any delivery, signed with that install's derived key and carrying every header
above:
{
"event_id": "evt_5f0c2a9e7b3d4c1a8e6f0b2d",
"type": "webhook.ping",
"occurred_at": "2026-10-08T10:42:18Z",
"tenant_id": "3f6a1c2e-8b4d-4e7f-9a10-5c2d8e7f6a31",
"install_id": "inst_7Hq2XcRm4P",
"payload_version": 2,
"origin": { "kind": "system", "id": null },
"data": {
"app": { "key": "acme_dialer", "name": "Acme Dialer" },
"check_run_id": "0b7d3e1f-5a2c-4e8b-9f6d-2c4e6a8b0d1f",
"message": "A test event from KudosCRM's review checks. Verify its signature, then answer 2xx."
}
}
Then the same ping arrives again with a fresh event_id, signed with a key that is not the install's.
The check passes when you answer the first 2xx and the second 400 or 401: verify the signature
before anything else, and answer a verified ping 2xx even though no handler of yours knows its type.
Replays
An admin can send any delivered or dead-lettered event again from Manage › Activity log. A replay
is a new delivery of the stored envelope with the same event_id, so a receiver that dedupes on it
does nothing twice. Replays queue behind live deliveries, and an event your app's grant no longer
covers is not replayed.
Lifecycle events
integration.installed arrives when the install first becomes active (your first code exchange).
integration.disconnected arrives when the install stops working for you, with a reason:
reason | When |
|---|---|
uninstalled | An admin uninstalled your app — sent even though the subscription is retired at that moment, signed and retried like any delivery. |
revoked | Your app revoked the grant the install runs on (POST /oauth/revoke with its refresh token). Revoking an earlier grant after a reconnect sends nothing. |
reauth_required | A code or refresh token was used twice, so KudosCRM revoked its grant — unless the admin has reconnected since. |
After revoked or reauth_required the install waits for an admin to reconnect it. Both events are
about your own install only and need no scope.