Skip to main content

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

info

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. origin tells you who caused everything else: a member (user), an automation, another app (app: its install's inst_… id, with its key in app), or the system (id: null). An app origin without app came from an integration with no install behind it — a legacy app credential, or an inbound source an admin registered — and its id is that source's uuid, not an inst_… 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's completed_by and a meeting's user need users:read, a ticket's requester needs contacts:read. An email activity needs emails: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_idevt_ + 24 hex. The same on every retry and every replay — dedupe on it.
typeThe event.
occurred_atWhen it happened, UTC.
tenant_idThe workspace (uuid).
install_idYour install — the one the token response named.
payload_versionThe version of data (2 for every event today).
originWho caused it: {kind, id, app?}.
dataThe 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 answerWhat KudosCRM does
2xxDelivered.
408, 429, 5xx, a timeout or a network errorRetried — 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 GoneDead-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:

reasonWhen
uninstalledAn admin uninstalled your app — sent even though the subscription is retired at that moment, signed and retried like any delivery.
revokedYour 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_requiredA 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.