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.
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:
- Create a subscription
Add your
target_urland theevent_typesyou care about (below). Save the signingsecretreturned once. - Verify every request
Recompute the HMAC and compare against
X-Kudos-Signaturebefore trusting the body. - Respond fast, dedupe
Return
2xxwithin 8 seconds; do slow work async. Dedupe on the body'sevent_id. - 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:

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.
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 afterwhsec_. 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.
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.
| Backoff | 30s → 2m → 10m → 30m → 1h → 2h |
| Max attempts | 6, then the delivery is marked failed |
| Auto-pause | after 10 consecutive failures the subscription flips to failing and stops delivering |
| Receiver timeout | respond 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
| Method | Path | Use |
|---|---|---|
GET | /api/v1/webhook_subscriptions | List |
POST | /api/v1/webhook_subscriptions | Create — returns secret once |
GET | /api/v1/webhook_subscriptions/:id | Detail |
PATCH | /api/v1/webhook_subscriptions/:id | Update name / url / event_types / status |
DELETE | /api/v1/webhook_subscriptions/:id | Remove |
POST | /api/v1/webhook_subscriptions/:id/rotate_secret | Rotate the secret — returns it once, old one valid for grace_hours |
POST | /api/v1/webhook_subscriptions/:id/test | Send a signed webhook.ping and get the receiver's answer inline |
GET | /api/v1/webhook_subscriptions/:id/deliveries | Delivery log — ?status=&event_type=&since= |
GET | /api/v1/webhook_subscriptions/:id/deliveries/:delivery_id | One delivery, with the payload we sent |
POST | /api/v1/webhook_subscriptions/:id/deliveries/:delivery_id/replay | Re-send it as a new delivery |
GET | /api/v1/webhook_events | The event catalog |
Limits
| Subscriptions per workspace | 10 |
| Events per subscription | 600 / minute |
| Deliveries per workspace | 2,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.