Report metadata
When your app hits an error, it can upload the logs and system details first. Bellhop answers with a link, and your app opens it in the person's browser. That page shows what was attached, lets them remove anything they'd rather not send, and asks what happened. The API never creates an issue on its own.
curl -X POST https://blhp.app/api/apps/acme/metadata \
-H "Authorization: Bearer $BELLHOP_REPORT_KEY" \
-H "Content-Type: application/json" \
--data @report.json
{
"title": "Crash on startup: config.json is a directory",
"message": "EISDIR: illegal operation on a directory, read\n at loadConfig (config.ts:88)",
"source": { "name": "Acme Desktop", "version": "2.4.1" },
"system": { "OS": "macOS 15.1", "CPU": "Apple M3", "Memory": "16 GB" },
"context": { "session": "ses_7f3a91c2", "workspace": "~/dev/acme" },
"logs": [{ "name": "main.log", "content": "10:42:01 INFO starting\n10:42:02 ERROR EISDIR config.json\n" }],
"fingerprint": "eisdir-loadconfig-88"
}
That's report.json. The response is 201 Created:
{
"id": "md_e2ecc33e92dc4924c5203a167dfb1d36abfff99dec88c227bbd0b90527c22834",
"url": "https://acme.blhp.app/new?metadata=md_e2ecc33e92dc4924c5203a167dfb1d36abfff99dec88c227bbd0b90527c22834",
"expires_at": "2026-11-11T16:48:33.782Z"
}
Open url in the person's browser. That's the whole integration.
Create a report key
Workspace admins create keys in Settings → Report keys. A key looks like bh_pub_ followed by 64 hex characters and is shown once, when it's created.
Report keys are publishable. A key can only upload report metadata to its own workspace: it can't read issues, post or change anything. That makes it safe to ship inside a desktop app, a mobile app or a web page. If one leaks or gets abused, revoke it in the same place and ship a new one. A revoked key gets 401 invalid_report_key.
Upload metadata
POST https://blhp.app/api/apps/{slug}/metadata
| Header | Value |
|---|---|
Authorization |
Bearer bh_pub_… |
Content-Type |
application/json (exactly; anything else gets 415) |
The body is a JSON object. Every field is optional.
| Field | Type | Limit | What it's for |
|---|---|---|---|
title |
string | 256 characters | The suggested issue title, for example the error's first line. The person can change it. |
message |
string | 8192 characters | The error message or stack. Shown as a block on the page. |
source |
object | name and version up to 120 characters each |
The app that sent it. name is required when source is present. Shown as "Attached from Acme Desktop 2.4.1". |
system |
object of strings | 64 fields; names up to 120 characters, values up to 2048 | System specs such as OS, CPU, GPU, Memory. |
context |
object of strings | 64 fields; names up to 120 characters, values up to 4096 | Anything else useful: a session id, the model, a feature flag. |
logs |
array of { name, content } |
20 logs; content up to 5 MiB each; name up to 255 characters |
Text logs. Names must be unique and can't contain /, \ or control characters. |
fingerprint |
string | 256 characters | A stable signature of the failure, such as a hash of the stack. Used to suggest matching open issues. |
system and context together can be up to 256 KiB. The whole request can be up to 10 MiB.
The response fields:
| Field | Description |
|---|---|
id |
The metadata id, md_ followed by 64 hex characters. |
url |
The new-issue page on the workspace with this metadata attached. Open it in a browser. |
expires_at |
When the metadata is deleted if nobody has sent the report by then, 7 days after the upload. |
CORS
This one endpoint can be called from any origin. Responses carry Access-Control-Allow-Origin: *, preflight OPTIONS requests get 204 with POST and the Authorization and Content-Type headers allowed, and no cookies are used.
The review page
The url opens https://<slug>.blhp.app/new?metadata=md_… (a https://blhp.app/new?metadata= link redirects there). The person sees:
- an "Attached from" panel with the message, system specs, logs (each opens in a viewer with search) and context;
- a remove button on every item, which deletes it from the metadata before anything is sent;
- the title you suggested, which they can edit, and a description box for what happened.
When they submit, the metadata is attached to the new issue. From then on only the workspace's team, its agents and the reporter can see the logs and specs; other visitors never do. Until it's attached, the id works like a capability: anyone with the link can open the page.
Fingerprints and matches
If you send a fingerprint and an open issue in the workspace already has metadata with the same one, the page says "This might be #34" and offers Add to #34 instead. Choosing it attaches the metadata to a comment on that issue rather than opening a new one. Only issues the person is allowed to see are suggested.
Expiry
Metadata that isn't attached within 7 days is deleted, logs included. Opening an expired link shows "This report has expired", and the person can start a blank report instead. Once attached, metadata stays with its issue or comment and doesn't expire.
Limits
| Limit | Value |
|---|---|
| Request body | 10 MiB |
| Logs per upload | 20 |
| One log's content | 5 MiB |
Fields in system or in context |
64 each |
system and context together |
256 KiB |
| Uploads per IP | 10 per minute |
| Uploads per report key | 15 per minute |
| Daily budget per report key | 500 uploads and 200 MiB per UTC day |
| Time to attach | 7 days |
The daily budget counts every accepted upload, including ones the person later trims or never sends.
Errors
| Status | Code | When |
|---|---|---|
| 400 | invalid_input |
The body isn't valid JSON or an object, or a field is the wrong type, empty when it can't be, or too long. The message says which, for example Invalid title. |
| 401 | invalid_report_key |
The Authorization header is missing or the key is wrong, revoked or for another workspace. |
| 413 | too_large |
The request, a log, the number of logs or the system/context size is over its limit. |
| 415 | unsupported_media_type |
Content-Type isn't application/json. |
| 429 | rate_limited |
Over the per-IP or per-key rate. Retry-After says how many seconds to wait. |
| 429 | report_daily_limit |
The key used up its daily budget. Retry-After counts down to midnight UTC. |
{ "error": { "code": "unsupported_media_type", "message": "Expected application/json" } }
From TypeScript
fetch works the same in browsers, Node, Bun, Deno and Electron:
const res = await fetch("https://blhp.app/api/apps/acme/metadata", {
method: "POST",
headers: {
Authorization: `Bearer ${REPORT_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
title: error.message.split("\n")[0],
message: error.stack,
source: { name: "Acme Desktop", version: APP_VERSION },
logs: [{ name: "main.log", content: recentLog }],
fingerprint: await hash(error.stack),
}),
})
if (!res.ok) throw new Error((await res.json()).error.message)
const { url } = await res.json()
A crash dialog
The usual pattern: catch the error, upload in the background while showing a dialog, and open the page when the person chooses to report it.
async function reportCrash(error: Error) {
// Start the upload right away so the link is ready when they click.
const upload = fetch("https://blhp.app/api/apps/acme/metadata", {
method: "POST",
headers: { Authorization: `Bearer ${REPORT_KEY}`, "Content-Type": "application/json" },
body: JSON.stringify({
title: error.message,
message: error.stack,
source: { name: "Acme Desktop", version: APP_VERSION },
system: { OS: os.version(), Memory: `${Math.round(os.totalmem() / 2 ** 30)} GB` },
logs: [{ name: "main.log", content: tail(LOG_FILE, 2000) }],
}),
}).then(async (res) => (res.ok ? (await res.json()).url : null))
const choice = await showDialog("Acme quit unexpectedly", ["Report…", "Close"])
if (choice !== "Report…") return
const url = await upload
// Without a link (offline, or a limit was hit), fall back to a blank report.
shell.openExternal(url ?? "https://acme.blhp.app/new")
}
os, shell, showDialog and tail stand in for your platform's APIs; in Electron they're node:os, electron's shell and dialog.