When feedback moves in your workspace, Crumb can tell your own services. Add an endpoint and Crumb POSTs a signed JSON event to it when a request is created, changes status, gets a reply, or moves in its tracker, and when initiatives change or new captures land. Use it to post to a channel, update a dashboard, or start your own automation. Events go out in the background, so a slow receiver never holds up your team.
Webhooks work on every plan, on Crumb Cloud and self-hosted.
Add an endpoint
Admins manage webhooks in Settings, Webhooks. A workspace can have up to 10 endpoints, and each one signs with its own secret.
- Paste the URL. It must be
httporhttps. On Crumb Cloud it must also resolve to a public address: private, loopback, and link-local targets are refused when you add the endpoint and again on every delivery. - Pick the events. Every type starts ticked, and Edit events changes the selection later. Event types added in later releases aren't ticked on endpoints you already have.
- Add the endpoint, then copy its signing secret. It's shown right away so you can store it on your receiver. Admins can reveal it again later.
http or https URL works, internal hosts included, since the box is yours.Test, rotate, pause
Each endpoint has these controls:
| Control | What it does |
|---|---|
| Send test | Posts one signed sample of the first event type the endpoint receives (in the order of the event table), flagged with X-Crumb-Test: 1, and tells you right away how it went. The body is that event's sample below, with your workspace slug and the current time. It's a single attempt with no retries. It shows in the delivery log, never counts toward a pause, and works on a paused endpoint. |
| Edit events | Changes which event types the endpoint receives. At least one stays ticked. |
| Reveal secret | Shows the endpoint's current signing secret, with a Copy button. |
| Rotate secret | After you confirm, a new secret replaces the old one at once and is shown for you to copy. Deliveries fail verification until your receiver has the new secret, so update it right away. |
| On/off switch | Pauses or resumes the endpoint. A paused endpoint gets no events, and none are saved up for later. Turning it back on resets its failure count. |
| Delete | Stops deliveries right away and deletes the endpoint's delivery log. |
The delivery log
Under each endpoint, Recent deliveries lists its 20 latest attempts: when, which event, the attempt number, the HTTP status, and the result in plain words. Every retry is its own row. Crumb keeps up to 100 attempts per endpoint and trims ones older than 30 days when the endpoint next gets a delivery.
| Result | Means | Retried |
|---|---|---|
| Delivered | Your server answered 2xx within 10 seconds. | No need |
| No response within 10 seconds | The request timed out. | Yes |
| Could not connect to the URL | The connection failed before any answer. | Yes |
| The URL did not resolve to a public address | Cloud only: the host now resolves to a private address, so Crumb refused to send. | Yes |
| Answered with a redirect, which is not followed | Your server answered 3xx. | No |
| Your server returned an error | Your server answered 5xx. | Yes |
| Your server rejected the request | Your server answered with another 4xx. | Only 429 |
The request
POST /crumb-webhook HTTP/1.1
Content-Type: application/json
User-Agent: Crumb-Webhooks/1
X-Crumb-Event: item.status_changed
X-Crumb-Event-Id: 3f2b8c1e-…
X-Crumb-Signature: sha256=<hex digest>
{
"id": "3f2b8c1e-…",
"type": "item.status_changed",
"workspace": "acme",
"item": { "short_id": "FB-42", "title": "CSV export times out on large accounts", "type": "bug" },
"from_status": "open",
"to_status": "planned",
"reason": null,
"at": "2026-01-01T12:00:00.000Z"
}The body is formatted here for reading; Crumb sends it as compact JSON. Every body has id, type, workspace (your workspace slug), and at (when it happened, ISO 8601 in UTC), plus the fields of its type.
| Header | Value |
|---|---|
Content-Type | application/json |
User-Agent | Crumb-Webhooks/1 |
X-Crumb-Event | The event type, the same as the body's type. |
X-Crumb-Event-Id | The event's id (a UUID), the same as the body's id. It's identical on every retry and for every one of your endpoints. |
X-Crumb-Signature | sha256= followed by the hex HMAC-SHA256 of the raw body, keyed with the endpoint's signing secret. |
X-Crumb-Test | 1 on samples from Send test. Absent on real events. |
Verify the signature
The signing secret is a 48-character hex string, one per endpoint. X-Crumb-Signature is sha256= followed by the lowercase hex HMAC-SHA256 of the request body exactly as sent, keyed with that secret. To check it:
- Read the raw body before any JSON parsing. A parsed and re-serialized body won't match.
- Compute the HMAC with the secret as-is, as text. Don't hex-decode it.
- Compare your result with the header in constant time, and answer
401when they differ.
In Node with Express:
import express from "express";
import { createHmac, timingSafeEqual } from "node:crypto";
const app = express();
app.post("/crumb-webhook", express.raw({ type: "application/json" }), (req, res) => {
const expected = Buffer.from(
"sha256=" + createHmac("sha256", process.env.CRUMB_WEBHOOK_SECRET).update(req.body).digest("hex"),
);
const got = Buffer.from(req.get("x-crumb-signature") ?? "");
if (got.length !== expected.length || !timingSafeEqual(got, expected)) return res.sendStatus(401);
const event = JSON.parse(req.body.toString("utf8"));
// Retries reuse event.id: skip ids you've already handled.
res.sendStatus(204);
});In Python with Flask:
import hashlib, hmac, json, os
from flask import Flask, abort, request
app = Flask(__name__)
SECRET = os.environ["CRUMB_WEBHOOK_SECRET"].encode() # as-is, don't hex-decode
@app.post("/crumb-webhook")
def crumb_webhook():
raw = request.get_data() # the exact bytes Crumb sent
expected = "sha256=" + hmac.new(SECRET, raw, hashlib.sha256).hexdigest()
got = request.headers.get("X-Crumb-Signature", "")
if not hmac.compare_digest(expected.encode(), got.encode()):
abort(401)
event = json.loads(raw)
# Retries reuse event["id"]: skip ids you've already handled.
return "", 204CRUMB_WEBHOOK_SECRET above. Rotating it in Settings, Webhooks takes effect at once, so put the new one on your receiver straight away.Delivery and retries
- Success is any
2xxanswered within 10 seconds. Acknowledge first and do slow work afterwards. - Retries: no response within 10 seconds, a connection error, a target refused on Cloud,
429, or any5xxis retried. Crumb makes up to 3 attempts: the first, a retry about 2 seconds later, and a last one about 10 seconds after that. Every attempt carries the same id, body, and signature. - Final answers:
3xxand other4xxresponses aren't retried. Redirects are never followed and count as failures, so register the final URL. - Deduplicate on the event id. A retry can arrive after you already processed the event, so store the
X-Crumb-Event-Idvalues you've handled and skip repeats. The signature covers only the body, so deduping is also what stops a replayed request. - Order isn't guaranteed. Events can arrive out of order; use
atto sort them. - No replay. Retries are held by the running app, so a restart drops any that are pending, and once its attempts are spent an event isn't sent again.
- Auto-pause. An event fails when its last attempt fails. After 15 failed events in a row, Crumb pauses the endpoint, and Settings shows the streak. A delivered event resets the count. Once your receiver is healthy, turn the endpoint back on in Settings, Webhooks, which resets the count too.
- Internal notes are never delivered.
Event types
Each endpoint receives only the types it's subscribed to.
| Type | Sent when |
|---|---|
item. | A request was created: from the widget, the dashboard, Slack, MCP, an accepted capture, or an Autopilot connector. |
item. | A request moved to a new status, including when the customer closes it. |
item. | Your team or the customer replied on a request. |
item. | A request was assigned to a teammate, or unassigned. |
item. | A request was merged into another one as a duplicate. |
item. | The Linear, Jira, or GitHub ticket linked to a request changed status in that tracker. |
ticket. | A request was linked to a new Linear, Jira, or GitHub ticket. |
ticket. | A request's tracker ticket was unlinked. |
customer. | The customer was emailed about a request: a reply or a status change. |
initiative. | An initiative was edited, moved to another roadmap column, or made public or private. |
capture. | Feedback was captured from email, Slack, the browser extension, or a connector, and waits for triage. |
Many payloads share an item reference, "item": { "short_id", "title", "type" }, where type is bug, idea, or question, and a ticket reference, "ticket": { "provider", "id", "url" }, where provider is linear, jira, or github and url can be null. The samples below leave out id, workspace, and at, which every event has.
Sample payloads
item.created: account is the customer account's name. A request an Autopilot connector folds into an existing one as a duplicate doesn't send it, since nothing new was created.
{
"type": "item.created",
"item": { "short_id": "FB-42", "title": "CSV export times out on large accounts", "type": "bug" },
"account": "Globex"
}item.status_changed: to_status is any status, including resolved when the customer closed it. reason is set when one was given, and from_status can be null.
{
"type": "item.status_changed",
"item": { "short_id": "FB-42", "title": "CSV export times out on large accounts", "type": "bug" },
"from_status": "open",
"to_status": "planned",
"reason": null
}item.reply_created: is_customer says which side wrote it. internal is always false here, since internal notes aren't delivered.
{
"type": "item.reply_created",
"item": { "short_id": "FB-42", "title": "CSV export times out on large accounts", "type": "bug" },
"reply": { "id": "0b6f7a52-…", "internal": false, "author": "Dana Reyes", "is_customer": false }
}item.assigned: assignee is null when the request was unassigned.
{
"type": "item.assigned",
"item": { "short_id": "FB-42", "title": "CSV export times out on large accounts", "type": "bug" },
"assignee": { "id": "5d1c2e9a-…", "name": "Dana Reyes" }
}item.merged: item is the duplicate, and into is the request it now lives under.
{
"type": "item.merged",
"item": { "short_id": "FB-57", "title": "CSV export times out on large accounts", "type": "bug" },
"into": { "short_id": "FB-42" }
}item.external_status_changed: the statuses are the tracker's own names, and either can be null.
{
"type": "item.external_status_changed",
"item": { "short_id": "FB-42", "title": "CSV export times out on large accounts", "type": "bug" },
"ticket": { "provider": "linear", "id": "ENG-128", "url": "https://linear.app/acme/issue/ENG-128" },
"from_status": "In Progress",
"to_status": "Done"
}ticket.linked:
{
"type": "ticket.linked",
"item": { "short_id": "FB-42", "title": "CSV export times out on large accounts", "type": "bug" },
"ticket": { "provider": "linear", "id": "ENG-128", "url": "https://linear.app/acme/issue/ENG-128" }
}ticket.unlinked: ticket is the one the request was linked to.
{
"type": "ticket.unlinked",
"item": { "short_id": "FB-42", "title": "CSV export times out on large accounts", "type": "bug" },
"ticket": { "provider": "linear", "id": "ENG-128", "url": "https://linear.app/acme/issue/ENG-128" }
}customer.notified: kind is reply (your team's reply was emailed) or status (a status change was, with to_status, which is null for replies). channel is email.
{
"type": "customer.notified",
"item": { "short_id": "FB-42", "title": "CSV export times out on large accounts", "type": "bug" },
"notification": { "kind": "status", "channel": "email", "to_status": "shipped" }
}initiative.updated: initiative is the state after the change, and changes names the fields it touched: name, description, internal_notes, status, color, owner, tracked_events, roadmap_column, or is_public. An initiative's status is open, in_progress, shipped, or parked, and roadmap_column is now, next, later, or null.
{
"type": "initiative.updated",
"initiative": { "short_id": "IN-7", "name": "Faster exports", "status": "in_progress", "roadmap_column": "now" },
"changes": ["roadmap_column"]
}capture.created: source is email, slack, extension, or a connector (gong, zendesk, intercom, freshdesk, freshchat), and subject can be null. It's sent only for captures that wait for triage, so status is pending. One that Autopilot accepts or dismisses on arrival doesn't send it.
{
"type": "capture.created",
"capture": { "id": "9a3e5c71-…", "source": "email", "subject": "Exports keep failing", "status": "pending" }
}