DocsDevelopers

API reference

Everything the widget does, you can do over HTTP. The customer API lives under /api/v1 on your Crumb origin (https://crumb-app.localhostlabs.net on Crumb Cloud, or your own host), takes and returns JSON, and acts for one signed-in customer: they see only their own requests, plus your public roadmap and changelog.

Working from your team's side instead, like reading the whole inbox or changing a status? That's an API key and the MCP server. To hear about changes as they happen, add a webhook.

Authenticate as a customer

These endpoints act as one of your customers, the same way the widget does. Send the signed identity token the widget uses (see how to sign one) as a bearer token:

Authorization: Bearer <signed identity token>

The token's claims pick the workspace, the customer, and their account, and Crumb creates the account and customer the first time it sees them. Crumb trusts the claims in the token, never values sent alongside it. On Crumb Cloud the token is required. A missing or failing token gets a 401 with one of these codes:

CodeMeans
jwt_requiredCloud only: no token was sent.
invalid_tokenNot a JWT, or its payload has no iss.
workspace_not_foundNo workspace has the slug in iss. This one is a 404.
jwt_signatureSigned with a different secret.
jwt_expiredexp has passed. Sign a fresh token; the widget asks for one through crumb.onTokenExpired.
jwt_ttlThe token lives longer than 7 days.
jwt_iatiat is in the future.
jwt_shape, jwt_jsonA required claim is missing, or the payload isn't JSON.

Endpoints

Paths are relative to your Crumb origin. "Token" means the identity token above.

EndpointAuthNotes
GET /api/v1/meTokenThe caller, their account, and the workspace settings the widget needs. Account admins also get their teammates.
GET /api/v1/itemsTokenThe caller's own requests, most recently active first. Not paginated.
POST /api/v1/itemsTokenSubmit a request.
GET /api/v1/items/{shortId}TokenOne of the caller's requests with its thread and status history. Internal notes never appear.
POST /api/v1/items/{shortId}TokenReply on one of the caller's requests.
POST /api/v1/items/{shortId}/closeTokenThe caller closes their own request, and it becomes Resolved.
POST /api/v1/uploadsTokenUpload one file to attach: a multipart file field, up to 10 MB.
GET /api/v1/uploads/{id}Signed link or tokenDownload an attachment. The links in a thread work for an hour on their own.
GET /api/v1/roadmapTokenYour public roadmap, and which initiatives the caller follows.
POST /api/v1/roadmapTokenFollow or unfollow a public initiative.
GET /api/v1/changelogTokenPublished public changelog entries, newest first.
POST /api/v1/notificationsTokenChange the caller's email preferences: any of replies, status, roadmap, and unsubscribed_all.
PATCH, DELETE /api/v1/membersToken, account adminChange a teammate's role in the caller's account, or remove them.
GET, POST, DELETE /api/v1/account/integrations/webhookToken only, account adminCheck, set, or clear the account's own Slack or Microsoft Teams channel.
POST /api/v1/usage-eventsTokenProduct usage events, what crumb.track() sends. Team plan and up on Cloud.
POST /api/v1/replay-sessions/{token}/chunksReplay sessionCloud only. The widget's session recorder uploads here; you don't call it yourself.
POST /api/v1/capturesCapture addressSend feedback to Needs triage from an extension or a server.

All of these except captures answer CORS preflights and send Access-Control-Allow-Origin: *, allowing GET, POST, PATCH, DELETE, and OPTIONS with the Content-Type and Authorization headers, so the widget can call them from your product's origin. The other routes under /api/v1 serve inbound email, scheduled jobs, billing, and the dashboard itself, so don't build on them. The self-hosting guide covers the ones an operator wires up.

Statuses

StatusMeans
openSubmitted, not yet looked at.
reviewIn review.
plannedPlanned.
progressIn progress.
shippedShipped. Closes the loop.
declinedWon't ship. Closes the loop, with a reason.
deferredSet aside for now, with a reason.
duplicateMerged into another request. Closes the loop, with a reason.
resolvedThe customer closed it themselves. Closes the loop.

Create an item

curl -X POST https://crumb-app.localhostlabs.net/api/v1/items \
  -H "Authorization: Bearer $CRUMB_IDENTITY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "type": "idea",
    "title": "Export a cohort as CSV",
    "body": "Without screenshotting the table, please."
  }'

type is bug, idea, or question, and title takes 1 to 300 characters. Optional: body (up to 20,000 characters, the thread's first message), attachment_ids (up to 50 ids from POST /api/v1/uploads), and context (the page, browser, and app version the customer was on; secret-looking query values are replaced with [redacted]). A 201 comes back with the new item's id, short_id (like FB-43), status (open), and created_at. Someone your team marked as spam gets 403 submitter_blocked.

Reply and close

POST /api/v1/items/{shortId} takes body (up to 20,000 characters), attachment_ids, or both, and lets your team know. POST /api/v1/items/{shortId}/close takes an optional reason and moves the request to resolved. Calling it again is harmless, and once your team has shipped, declined, or merged the request it answers 409 already_closed. Both answer 403 not_your_item for a request someone else submitted.

Roadmap and changelog

GET /api/v1/roadmap returns the initiatives your team made public and placed in Now, Next, or Later, plus the public ones shipped most recently (up to 10, newest first):

{
  "columns": {
    "now": [
      { "id": "1c9e…", "short_id": "IN-7", "name": "Faster exports", "description": "Exports that include every row.",
        "status": "in_progress", "lane": "now", "shipped_at": null, "following": true }
    ],
    "next": [],
    "later": [],
    "shipped": []
  }
}

following says whether the caller follows an initiative, and shipped_at is set only on shipped ones. To follow or unfollow, post { "initiative_id": "…", "follow": true } to the same path; followers are emailed when it moves, if their roadmap preference is on. GET /api/v1/changelog returns { "entries": [...] }, each with id, title, body, and published_at.

Send usage events

curl -X POST https://crumb-app.localhostlabs.net/api/v1/usage-events \
  -H "Authorization: Bearer $CRUMB_IDENTITY_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "events": [{ "name": "report_exported", "props": { "format": "csv" } }] }'

Up to 50 events per call, each with a name of 1 to 64 characters, optional props (an object; the first 30 keys are kept), and an optional ts (ISO 8601, defaults to when it arrived). A 202 answers { "accepted": 1 }. On Cloud this needs the Team plan or higher, with room for 500,000 events a month on Team and 2,000,000 on Growth (then 429 usage_events_cap_reached). Self-hosted installs take them unmetered.

Capture from an extension

POST /api/v1/captures drops feedback into Needs triage, where your team confirms it onto an account or discards it. It doesn't create an item by itself. Instead of a customer token it uses your workspace's capture address, inbox+<slug>.<token>@<domain> from Settings, Install: send its slug and token parts. (Self-hosted, the address appears once CRUMB_INBOUND_DOMAIN and CRUMB_INBOUND_SECRET are set.)

curl -X POST https://crumb-app.localhostlabs.net/api/v1/captures \
  -H "Content-Type: application/json" \
  -d '{
    "slug": "your-workspace",
    "token": "<token part of your capture address>",
    "from_email": "[email protected]",
    "from_name": "Maya Chen",
    "subject": "Exports keep failing",
    "body": "Anything over 50k rows fails after a minute."
  }'

Send a subject (up to 300 characters), a body (up to 20,000), or both. It answers { "ok": true, "captureId": "…" }, or 401 unauthorized for a wrong token. It isn't CORS-enabled, so call it from an extension or a server.

Errors

A failed call answers with an HTTP status and a stable, snake_case code:

{ "error": "item_not_found" }

Codes are for your code, not for people, so map them to your own words before showing them. Request-body validation answers 400 with the first failing field's code, like missing_title or title_too_long; a field with the wrong JSON type gets a 400 with a validation message instead. Timestamps are ISO 8601 in UTC, and responses can gain new fields over time, so ignore ones you don't recognize.

Rate limits

Each path below has a bucket per source IP, and some add a looser one per workspace once the caller is identified. A bucket holds a burst of requests and refills at a steady rate per second. An empty one answers:

HTTP/1.1 429 Too Many Requests
Retry-After: 4

{ "error": "rate_limited", "retry_after_seconds": 4 }
PathPer IP (burst / per second)Per workspace
POST items, replies, and close60 / 1 (default)600 / 10
POST /api/v1/uploads20 / 0.33
/api/v1/roadmap, /api/v1/changelog120 / 2
/api/v1/notifications, /api/v1/members, /api/v1/captures60 / 1
/api/v1/account/integrations/webhook60 / 1 (default)
POST /api/v1/usage-events60 / 1 (default)1,200 / 40, plus the monthly cap on Cloud
POST /api/v1/replay-sessions/{token}/chunks120 / 2600 / 20

Each path has its own buckets, shared by its methods. GET on /me, /items, /items/{shortId}, and /uploads/{id} isn't rate limited. Self-hosted, the default can be changed with CRUMB_RATE_LIMIT_CAPACITY and CRUMB_RATE_LIMIT_REFILL_PER_SEC. The source IP comes from CF-Connecting-IP, then X-Real-IP, then the last X-Forwarded-For hop.

Self-host only: no token

When you self-host, a request without a token can name the workspace and customer itself: workspace_slug and account_user_email in the body (form fields for uploads), or ?workspace= and &email= on a GET. Creating an item or sending usage events for someone new also takes account_name and account_user_name. It trusts whoever calls it, so it's for testing, and the account channel endpoint doesn't take it. Crumb Cloud rejects it with jwt_required.

curl -X POST https://your-crumb-host/api/v1/items \
  -H "Content-Type: application/json" \
  -d '{
    "workspace_slug": "your-workspace",
    "account_user_email": "[email protected]",
    "account_user_name": "Maya Chen",
    "account_name": "Acme Co",
    "type": "idea",
    "title": "Export a cohort as CSV"
  }'

It answers 400 missing_workspace_slug or 400 missing_email when a field is missing, and 404 user_not_found for an unknown email with no account_name to create the person with.