Building for the browser? Use the Web SDK instead. It calls all of this for you, and it collects the console, network and environment context that makes a report worth filing — which this API accepts but cannot gather on its own.
Authentication
Every ingest route takes a workspace API key, in either of two headers. They are equivalent; use whichever your client makes easier.
Authorization: Bearer bc_live_…
X-BugCatch-Key: bc_live_…
Get a key from Workspace Settings → API Keys. It is shown once — only a hash and a display prefix are stored, so there is no screen that can show it to you again.
What the key can and cannot do
This key is publishable. It is designed to sit in your page source where anyone can read it, which is only safe because of how little it can reach.
| Can | Cannot |
|---|---|
| File a report into any project in its workspace | List, read or search your bugs |
| List that workspace's projects (id, name, key) | Read another reporter's thread |
| Presign an attachment upload | Change any setting, member or plan |
| Start and confirm a reporter sign-in | Reach anything in the dashboard |
Reading a report back needs a watch token instead — a separate credential handed out when a report is filed, which opens that reporter's own reports and nothing else. See reading reports back.
Do not use this key as a server credential for anything that matters. It is not a secret, it has no scopes to narrow, and treating a leak of it as an incident will waste your time. If you need to read data out of BugCatch programmatically, talk to us — that is not what this key is.
CORS
The /ingest/ routes accept any origin. The SDK runs on your site and
we cannot know its address ahead of time, so there is nothing to allowlist and no
domain to send us. That is safe because these routes authenticate with a header and
never with a cookie: reflecting the origin grants a browser exactly what
curl already had. Every other route on the API keeps a strict allowlist.
Filing a report
The one endpoint most integrations ever call.
curl -X POST https://api.bugcatch.sphoro.com/ingest/bugs \
-H "Authorization: Bearer bc_live_…" \
-H "Content-Type: application/json" \
-d '{
"projectId": "…",
"title": "Checkout returns 500 on card payment",
"description": "Only with Amex. Visa is fine.",
"priority": "HIGH",
"url": "https://shop.acme.com/checkout",
"reporterEmail": "alex@acme.com",
"reporterName": "Alex",
"metadata": { "env": "production", "build": "2026.8.19-4" }
}'
Body
| Field | Type | Notes |
|---|---|---|
projectId | string required | Must belong to the key's workspace, or the call is a 400. |
title | string required | What the reporter would say happened. |
description | string | Free text. |
priority | string | LOW · MEDIUM · HIGH · CRITICAL. Defaults to MEDIUM. |
url | string | Where it happened. Scrubbed for credentials on the way in. |
metadata | object | Anything you want on the report — build number, tenant, feature flags. |
logs | object | Console, network and environment. See the shape. |
reporterEmail | string | Threads their reports together and is where the resolution mail goes. Only send addresses you have authenticated. |
reporterName | string | For display. |
attachments | array | { type, storageKey, mime }. See attachments. |
Response
{
"id": "clz9…",
"redactionsApplied": 2,
"watchToken": "…",
"estimate": { "lowHours": 48, "highHours": 120, "label": "2–5 days" }
}
redactionsApplied— how many secrets the server stripped on intake. Worth logging; a number that is never zero means something upstream is leaking credentials into URLs.watchToken— returned only when the report could be attributed to a reporter. Keep it if you want to read the report back later.estimate— how long it is likely to take, as a band and a ready-to-print phrase.nullwhen the project has estimates switched off, has decided reporters do not see them, or has nothing closed to base one on. Draw nothing in that case — a confirmation that admits it cannot answer is worse than one that never raised the question.
Sending captured context
The optional logs object is what turns a description into something
reproducible. Every field is optional; send what you have.
{
"logs": {
"console": {
"entries": [
{ "level": "error", "text": "Uncaught TypeError: t.map is not a function",
"at": "2026-08-25T09:14:02.881Z" }
]
},
"network": {
"entries": [
{ "url": "https://api.acme.com/v1/cart", "method": "POST",
"status": 500, "ms": 1841, "failed": true, "slow": true,
"at": "2026-08-25T09:14:02.401Z" }
]
},
"env": {
"userAgent": "…", "platform": "iOS", "language": "en-GB",
"viewport": "390x844", "timezone": "Europe/London"
}
}
}
| Field | Shape |
|---|---|
console.entries[] | level, text, at. Levels are the console.* names; uncaught errors go here as error. |
network.entries[] | url, method, status, ms, and the booleans failed and slow so triage can find the interesting rows without reading everything. Optional error. |
env | A flat object. There is no required key — send what your platform can tell you. |
Do not send request or response bodies. The SDK never reads them, and the report format has nowhere to put them. If you send secrets in a URL they are stripped on intake and again when the report is read back — but the reliable place to not leak a credential is before it is sent.
Attachments
Two steps, because the bytes never pass through the report call: ask for a URL, upload to it, then reference the key it gave you.
-
Presign
POST /ingest/presigncurl -X POST https://api.bugcatch.sphoro.com/ingest/presign \ -H "X-BugCatch-Key: bc_live_…" \ -H "Content-Type: application/json" \ -d '{ "mime": "image/png" }' # → { "key": "uploads/2026-08-25/8f2a….png", "uploadUrl": "https://…" }The URL is good for five minutes. An unsupported
mimeis a400before you have uploaded anything. -
PUT the bytes
straight to the upload URLcurl -X PUT "<uploadUrl>" \ -H "Content-Type: image/png" \ --data-binary @screenshot.pngSend exactly the
Content-Typeyou presigned. It is bound into the signature, so a different one is refused. -
Reference the key on the report
in the POST /ingest/bugs body"attachments": [ { "type": "SCREENSHOT", "storageKey": "uploads/2026-08-25/8f2a….png", "mime": "image/png" } ]
| Where | Accepted types |
|---|---|
| On a report typed SCREENSHOT or VIDEO |
image/png, image/jpeg, image/webp, video/webm, video/mp4 |
| On a message a reply in the thread |
The above, plus image/gif, audio/* voice notes, and documents: PDF, txt, csv, json, zip, doc(x), xls(x) |
A document has no honest place on a report, because a report's attachments are typed
SCREENSHOT or VIDEO and one filed as a screenshot renders as a
broken image. Put it on a message, where it keeps its own type.
Listing projects
Useful for a setup screen, so nobody has to paste an id by hand.
curl https://api.bugcatch.sphoro.com/ingest/projects \
-H "X-BugCatch-Key: bc_live_…"
# → [{ "id": "clz9…", "name": "Storefront", "key": "WEB" }]
Returns every project in the key's workspace, and nothing from any other.
Reading reports back
Deliberately not something the API key can do. Reading uses the watch token returned when a report was filed — a credential that opens that reporter's own reports and nothing else.
The reasoning: the key is publishable and sits in page source, so a key-authenticated feed keyed on an email address would let anyone read anyone's reports by guessing an address.
curl https://api.bugcatch.sphoro.com/ingest/watch/reports \
-H "Authorization: Bearer <watchToken>"
| Endpoint | Returns |
|---|---|
GET /ingest/watch/reports |
Everything this token's holder filed, newest first, with each one's status. |
GET /ingest/watch/reports/:bugId |
One report in full, with its timeline — every step from filing to now, both sides' messages included. |
POST /ingest/watch/reports/:bugId/messages |
Write back on it. Body or attachments — at least one. |
GET /ingest/watch/stream |
Server-sent events: status changes, severity changes, assignment and shared replies, as they happen. Sends a ping every 25 seconds. |
Internal comments never appear on any of these. Only a comment a team member
explicitly marked as visible to the reporter is sent. And the assignee is never
named — assigned: true says the report has an owner; who that is stays
inside the workspace.
Reporter sign-in
Where a project asks reporters to prove their address, these are the three calls behind it. All take the API key; the session they mint goes in its own header afterwards.
| Endpoint | Does |
|---|---|
GET /ingest/login?projectId=… | What this project asks (off / optional / required), and who this session is. |
POST /ingest/login/start | Mails a code to any address. |
POST /ingest/login | Spends the code, returning a session token. |
POST /ingest/logout | Ends it, here and on the server. |
Authorization: Bearer bc_live_… // which workspace this page reports to
X-BugCatch-Session: … // who is sitting in front of it
Two headers because they answer two different questions, and a request carries both.
Where a session is present it wins: the address on the report is the one that answered
the code, never the one in reporterEmail.
One live session per address per project. Signing in somewhere else ends this one immediately.
Errors
| Status | Means | What to do |
|---|---|---|
400 |
The body failed validation, or the project does not belong to this key's workspace, or the mime type is not accepted. | Read the message — it names the field. Retrying unchanged will not help. |
401 |
Missing, invalid or revoked key. Or, on a project requiring sign-in, no live reporter session. | Check the key, or sign the reporter in. |
403 |
The workspace is suspended. The key is valid and re-issuing it will not help. | Do not treat this as a credential problem or rotate keys. Contact support. |
404 |
No such report, or a watch token that does not open it. | A token opens only its own reports, never another reporter's. |
Errors are JSON, in Nest's standard shape:
{
"statusCode": 400,
"message": "That project does not belong to this API key.",
"error": "Bad Request"
}
Retrying. 401 and 400 are permanent for that request
— a client that retries them is filing nothing and generating load. 403
is also permanent. Retry 5xx and network failures with a backoff, and
queue reports locally if your client can, since a bug report is worth filing late.
End to end, in one script
A screenshot and a report, in the order a client would do it. Nothing here needs the SDK.
#!/usr/bin/env bash
set -euo pipefail
API=https://api.bugcatch.sphoro.com
KEY=bc_live_…
PROJECT=…
# 1. Ask for somewhere to put the screenshot.
PRESIGN=$(curl -sS -X POST "$API/ingest/presign" \
-H "X-BugCatch-Key: $KEY" -H "Content-Type: application/json" \
-d '{"mime":"image/png"}')
KEY_PATH=$(echo "$PRESIGN" | jq -r .key)
UPLOAD_URL=$(echo "$PRESIGN" | jq -r .uploadUrl)
# 2. Upload it. The bytes never pass through the API's report route.
curl -sS -X PUT "$UPLOAD_URL" \
-H "Content-Type: image/png" --data-binary @screenshot.png
# 3. File the report, referencing the key from step 1.
curl -sS -X POST "$API/ingest/bugs" \
-H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d "$(jq -n --arg p "$PROJECT" --arg k "$KEY_PATH" '{
projectId: $p,
title: "Checkout returns 500 on card payment",
description: "Only with Amex. Visa is fine.",
priority: "HIGH",
url: "https://shop.acme.com/checkout",
reporterEmail: "alex@acme.com",
metadata: { env: "production" },
attachments: [{ type: "SCREENSHOT", storageKey: $k, mime: "image/png" }]
}')" | jq
Versioning and stability
There is no version prefix in the path, and no dated version header. What that means in practice:
- Fields get added. Parse responses tolerantly — an unfamiliar key in a response is not an error, and a client that rejects one will break on the next release.
- The request shapes above are stable. Optional fields stay optional and required ones stay required.
- Pin the SDK version if you use it. That is the piece with a version number to pin, and an unpinned CDN URL changes behaviour on a site nobody redeployed.
If you are building something that depends on this API more deeply than filing reports, tell us what you need before you build it.
Still stuck?
Every answer here describes what the product actually does today. If something behaves differently, that is worth telling us about — it means the page is wrong.
- Support
- support@sphoro.com
- Security
- info@sphoro.com
- Talk to us
- Book 20 minutes