DocsDevelopers

Webhooks

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.

  1. Paste the URL. It must be http or https. 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.
  2. 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.
  3. 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.
Self-hosting? Any http or https URL works, internal hosts included, since the box is yours.

Test, rotate, pause

Each endpoint has these controls:

ControlWhat it does
Send testPosts 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 eventsChanges which event types the endpoint receives. At least one stays ticked.
Reveal secretShows the endpoint's current signing secret, with a Copy button.
Rotate secretAfter 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 switchPauses 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.
DeleteStops 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.

ResultMeansRetried
DeliveredYour server answered 2xx within 10 seconds.No need
No response within 10 secondsThe request timed out.Yes
Could not connect to the URLThe connection failed before any answer.Yes
The URL did not resolve to a public addressCloud only: the host now resolves to a private address, so Crumb refused to send.Yes
Answered with a redirect, which is not followedYour server answered 3xx.No
Your server returned an errorYour server answered 5xx.Yes
Your server rejected the requestYour 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.

HeaderValue
Content-Typeapplication/json
User-AgentCrumb-Webhooks/1
X-Crumb-EventThe event type, the same as the body's type.
X-Crumb-Event-IdThe 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-Signaturesha256= followed by the hex HMAC-SHA256 of the raw body, keyed with the endpoint's signing secret.
X-Crumb-Test1 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:

  1. Read the raw body before any JSON parsing. A parsed and re-serialized body won't match.
  2. Compute the HMAC with the secret as-is, as text. Don't hex-decode it.
  3. Compare your result with the header in constant time, and answer 401 when 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 "", 204
Keep the secret on your server, like CRUMB_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 2xx answered 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 any 5xx is 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: 3xx and other 4xx responses 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-Id values 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 at to 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.

TypeSent when
item.createdA request was created: from the widget, the dashboard, Slack, MCP, an accepted capture, or an Autopilot connector.
item.status_changedA request moved to a new status, including when the customer closes it.
item.reply_createdYour team or the customer replied on a request.
item.assignedA request was assigned to a teammate, or unassigned.
item.mergedA request was merged into another one as a duplicate.
item.external_status_changedThe Linear, Jira, or GitHub ticket linked to a request changed status in that tracker.
ticket.linkedA request was linked to a new Linear, Jira, or GitHub ticket.
ticket.unlinkedA request's tracker ticket was unlinked.
customer.notifiedThe customer was emailed about a request: a reply or a status change.
initiative.updatedAn initiative was edited, moved to another roadmap column, or made public or private.
capture.createdFeedback 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" }
}