Skip to main content

Outbound webhooks

Outbound webhooks are the flip side of the producer webhook integration. Instead of sending events to the CRM, you subscribe your own endpoint to CRM events — so any external service (Zapier, Make, or a custom integration) is notified the moment something changes, without polling.

info

This is the path for your tools reacting to your CRM data. It needs no OAuth and no partner onboarding — create a subscription, verify the signature, and you're live.

How delivery works​

Set one up in four steps:

  1. Create a subscription

    Add your target_url and the event_types you care about (below). Save the signing secret returned once.

  2. Verify every request

    Recompute the HMAC and compare against X-Kudos-Signature before trusting the body.

  3. Respond fast, dedupe

    Return 2xx within 8 seconds; do slow work async. Dedupe on the body's event_id.

  4. Handle failures

    A non-2xx or timeout triggers the retry schedule. Ten consecutive failures auto-pause the subscription.

Subscribe​

Create subscriptions in Settings → Integrations → Outbound webhooks, or over the API:

The New webhook dialog — a name, target URL, the event types that fire a POST, and an active/paused status.

curl -X POST https://api.kudoscrm.com/api/v1/webhook_subscriptions \
-H "Authorization: Bearer pat_…" \
-H "Content-Type: application/json" \
-d '{ "data": { "attributes": {
"name": "ops-slack-relay",
"target_url": "https://hooks.example.com/kudos",
"event_types": ["contact.created", "deal.won"]
} } }'

The response includes the signing secret once — store it. Only a 6-char suffix is returned thereafter.

warning

Production target_urls must be https:// — this is enforced, not advisory. URLs carrying embedded credentials (https://user:pass@…) are rejected. Rotate the secret via rotate_secret (below) if you suspect it has leaked.

Event catalog (v1)​

contact.created company.created audit_log.created
contact.updated company.updated audit_log.high_severity
contact.deleted company.deleted anomaly.detected
contact.restored deal.created import.started
activity.created deal.stage_changed import.completed
task.completed deal.won import.failed
integration.installed deal.lost import.reverted
integration.disconnected

Adding new event types is safe; renaming an existing one is a breaking change.

Rather than hardcoding this list, read GET /api/v1/webhook_events — it returns every event with its group and a one-line description, and new events appear there first.

Wildcards. Subscribe to deal.* for every event in a group (including ones we ship later) or * for everything. A wildcard over a group that does not exist is rejected when you save it, rather than silently matching nothing forever.

Verify the signature​

Every delivery carries both a Stripe-style header and the Standard Webhooks headers. Verify whichever suits your stack — you do not need both.

X-Kudos-Signature: t=1700000000,v1=<hmac-sha256 hex>
X-Kudos-Event: contact.created
X-Kudos-Delivery: <delivery id>
X-Kudos-Delivery-Attempt: 1

webhook-id: <delivery id>
webhook-timestamp: 1700000000
webhook-signature: v1,<hmac-sha256 base64>
  • X-Kudos-Signature — HMAC-SHA256(secret, "<t>.<raw_body>"), hex, keyed by the secret string exactly as we gave it to you.
  • webhook-signature — HMAC-SHA256(decoded_secret, "<id>.<timestamp>.<raw_body>"), base64, keyed by the base64-decoded bytes after whsec_. This is what off-the-shelf Standard Webhooks libraries expect.

Sign over the raw request body, before any JSON parsing — re-serializing changes the bytes.

warning

While a secret rotation is in progress, every delivery carries TWO signatures — the new secret's and the old one's. Accept the request if any of them matches. A verifier that parses the header into a dictionary keyed by v1 keeps only one of the two and will reject half of your traffic during a rotation.

import hmac, hashlib, time, re

def verify(raw_body: str, header: str, secret: str, tolerance_s: int = 300) -> bool:
t = int(re.search(r"t=(\d+)", header).group(1))
if abs(time.time() - t) > tolerance_s:
return False # stale — reject replays
expected = hmac.new(secret.encode(), f"{t}.{raw_body}".encode(), hashlib.sha256).hexdigest()
# every v1= — a rotation window sends two
return any(hmac.compare_digest(sig, expected)
for sig in re.findall(r"v1=([0-9a-f]{64})", header))

Rotating a secret​

POST /api/v1/webhook_subscriptions/:id/rotate_secret returns the new secret once and keeps the old one verifying for 24 hours by default (grace_hours, max 168). Deploy the new secret any time inside that window — no events are dropped in between. Pass grace_hours: 0 to cut over immediately, which is what you want if the old secret has leaked.

Idempotency​

Retries of the same event share an event_id (evt_<24hex>) in the body. Dedupe on it — a receiver must tolerate the same event arriving more than once.

Retries and failure handling​

Deliveries retry when the same request could plausibly get a different answer — 408, 429, 5xx and transport errors. Every other 4xx is your verdict on this payload and a replay would get the same answer, so we stop immediately and dead-letter it instead of spending two hours proving the point. 410 Gone additionally parks the subscription: it is how a receiver deregisters itself. Redirects are never followed, so a 3xx is terminal too.

Backoff30s → 2m → 10m → 30m → 1h → 2h
Max attempts6, then the delivery is marked failed
Auto-pauseafter 10 consecutive failures the subscription flips to failing and stops delivering
Receiver timeoutrespond within 8 seconds — slower counts as a failure

Return a 2xx as soon as you've accepted the event; do slow work asynchronously.

Manage subscriptions​

MethodPathUse
GET/api/v1/webhook_subscriptionsList
POST/api/v1/webhook_subscriptionsCreate — returns secret once
GET/api/v1/webhook_subscriptions/:idDetail
PATCH/api/v1/webhook_subscriptions/:idUpdate name / url / event_types / status
DELETE/api/v1/webhook_subscriptions/:idRemove
POST/api/v1/webhook_subscriptions/:id/rotate_secretRotate the secret — returns it once, old one valid for grace_hours
POST/api/v1/webhook_subscriptions/:id/testSend a signed webhook.ping and get the receiver's answer inline
GET/api/v1/webhook_subscriptions/:id/deliveriesDelivery log — ?status=&event_type=&since=
GET/api/v1/webhook_subscriptions/:id/deliveries/:delivery_idOne delivery, with the payload we sent
POST/api/v1/webhook_subscriptions/:id/deliveries/:delivery_id/replayRe-send it as a new delivery
GET/api/v1/webhook_eventsThe event catalog

Limits​

Subscriptions per workspace10
Events per subscription600 / minute
Deliveries per workspace2,000 / minute

Over the limit, events are dropped rather than queued, and the count appears as emit_dropped_count on the subscription — so a throttled integration reads as throttled rather than broken. Talk to us if you need a higher ceiling.