Webhooks
Signed events pushed to your endpoint, the published retry curve you can size your outage tolerance against, and how to verify a delivery.
A webhook endpoint is a URL Krrim delivers events to. Take events instead of polling and you stop spending rate-limit budget on questions whose answer is usually "nothing changed".
Create endpoints in Settings → Webhooks (organization owner or admin), or
through /api/v1/webhooks/endpoints/ with the webhooks:write scope.
The envelope
Every delivery is a POST with the same top-level shape, whatever the event:
{
"id": "evt_01J8Z9K3QW5N7T2R",
"type": "task.status_changed",
"api_version": "v1",
"occurred_at": "2026-08-25T09:14:03.117000Z",
"source": "web",
"correlation_id": "cor_01J8Z9K3QW5N7T2S",
"organization": { "id": 3, "slug": "acme", "name": "Acme" },
"actor": { "id": 14, "email": "dana@acme.com", "name": "Dana Okafor" },
"data": {
"task": { "key": "krrim-0042", "title": "Investigate checkout timeout" },
"from": "in_review",
"to": "done"
}
}| Field | Notes |
|---|---|
id | Unique per event. Key your deduplication on this |
type | See the catalog below |
api_version | Bumped only for a breaking payload change |
correlation_id | Shared by every event from one action, so you can group them |
source | Where the action came from — the app, the API, a scheduled job |
actor | Who did it. A service account appears here by name |
Ignore unknown keys. Adding a field is not a breaking change and does not
bump api_version, so a consumer that rejects unexpected keys will break on a
release that was safe for everyone else.
Event catalog
| Event | Fires when |
|---|---|
task.created | A task is created |
task.updated | Any field on a task changes |
task.status_changed | A task moves between statuses |
task.assigned | A task's assignee changes |
task.deleted | A task is deleted |
task.due_soon | A task's due date approaches |
task.escalated | Stuck work is escalated |
comment.created | A comment is posted |
webhook.test | You pressed "send test event" |
Every event in this list has a real emission site in Krrim, and a test walks the source tree to prove it. A catalog entry nothing can produce is worse than a missing one — you would subscribe and then wait forever for a delivery, with no error to explain it.
webhook.test is delivered but not subscribable: it goes to one endpoint
because a human pressed the button on that endpoint, which is a stronger
statement of intent than a checkbox.
The live catalog, including which events are subscribable, is at
GET /api/v1/webhooks/catalog/.
Verifying a delivery
Every request carries a signature over the timestamp and the raw body:
X-Krrim-Signature: sha256=<hex>
X-Krrim-Timestamp: 1756113243The signed string is {timestamp}.{raw_body}, HMAC-SHA256 with your endpoint's
signing secret.
import hashlib, hmac, time
def verify(raw_body: bytes, signature: str, timestamp: str, secret: str) -> bool:
# Reject anything too old: the timestamp is inside the digest precisely so
# a captured delivery cannot be replayed against you forever.
if abs(time.time() - int(timestamp)) > 300:
return False
signed = f"{timestamp}.".encode() + raw_body
expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
return hmac.compare_digest(f"sha256={expected}", signature)Sign the raw bytes, before any JSON parsing. Re-serializing the parsed body
reorders keys and changes whitespace, and the signature will never match.
Always compare with a constant-time function, never ==.
There is also POST /api/v1/webhooks/verify/, which checks a signature for you
while you are getting an integration working.
Retries
If your endpoint does not answer 2xx, Krrim retries on a fixed, published
curve:
1 minute → 5 minutes → 30 minutes → 2 hours → 6 hoursSix attempts in total, then the delivery is abandoned. The curve is fixed rather than exponential-with-jitter because integrators size their outage tolerance against it, and a number you cannot predict is not one you can plan around.
- 5 second timeout per attempt.
- Redirects are never followed. A
3xxis a failure. - Answer
2xxas soon as you have durably accepted the event; do the work afterwards. A slow handler burns the timeout and turns a successful delivery into a retry.
Auto-disable
An endpoint is disabled automatically after 20 consecutive abandoned deliveries — deliveries, not attempts. Counting attempts would let a half-hour outage on a busy workspace disable a perfectly healthy endpoint.
You are notified when it happens, and re-enabling is a click.
Replay
Delivery history is kept and browsable, and any delivery can be replayed:
POST /api/v1/webhooks/deliveries/{id}/replay/A replay re-sends the original envelope and the original event id, not a
fresh snapshot of the resource. That is what makes it useful for backfilling a
consumer that was down: you get what you would have got at the time, not what
the task looks like now. Your deduplication on id will see it as the same
event.
URL requirements
Endpoint URLs must be HTTPS, and must not resolve to a loopback, private (RFC1918) or link-local address.
This is checked when you save the endpoint and again on every delivery attempt, against every address the name resolves to — not just the first. An outgoing webhook is an SSRF primitive we hand you by design, and DNS can change between save and send, which is exactly what rebinding is.