Sending reports via the API
Submit bug reports, feature requests, and suggestions directly to the Bughorn HTTP API — no SDK required.
Every Bughorn SDK is a thin wrapper around a single HTTP endpoint. If there's no SDK for your platform — or you'd rather not add a dependency — you can send reports yourself with any HTTP client.
The endpoint
POST https://bughorn.com/api/v1/submitThat's the production base URL. For local development, use
http://localhost:3000 instead.
Authentication
Authenticate with a project API key, sent as a Bearer token:
Authorization: Bearer bh_xxxxxxxxxxxxxxxxxxxxxxxxYou can find (and rotate) a project's key under Project → Settings → API keys.
Bughorn API keys are publishable — they're designed to ship inside client
apps (mobile, web, desktop). A key only ever lets a caller create reports
for its own project; it grants no read access and can be rotated or revoked at
any time. The report's project is derived entirely from the key, so there is
no projectId field to set (and any attempt to send one is rejected).
Quick start
Grab your project API key
Copy the key from Project → Settings → API keys. It starts with bh_.
Send a report
curl -X POST https://bughorn.com/api/v1/submit \
-H "Authorization: Bearer bh_xxxxxxxxxxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{
"type": "bug",
"title": "Crash on checkout",
"message": "Tapping \"Pay\" on the cart screen crashes the app.",
"end_user_email": "[email protected]",
"context": { "app_version": "1.4.2", "os": "iOS 18.1" }
}'await fetch("https://bughorn.com/api/v1/submit", {
method: "POST",
headers: {
"Authorization": "Bearer bh_xxxxxxxxxxxxxxxxxxxxxxxx",
"Content-Type": "application/json",
},
body: JSON.stringify({
type: "bug",
title: "Crash on checkout",
message: 'Tapping "Pay" on the cart screen crashes the app.',
end_user_email: "[email protected]",
context: { app_version: "1.4.2", os: "iOS 18.1" },
}),
});let url = URL(string: "https://bughorn.com/api/v1/submit")!
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("Bearer bh_xxxxxxxxxxxxxxxxxxxxxxxx", forHTTPHeaderField: "Authorization")
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try JSONSerialization.data(withJSONObject: [
"type": "bug",
"title": "Crash on checkout",
"message": "Tapping \"Pay\" on the cart screen crashes the app.",
"end_user_email": "[email protected]",
"context": ["app_version": "1.4.2", "os": "iOS 18.1"],
])
let (_, response) = try await URLSession.shared.data(for: request)import requests
requests.post(
"https://bughorn.com/api/v1/submit",
headers={"Authorization": "Bearer bh_xxxxxxxxxxxxxxxxxxxxxxxx"},
json={
"type": "bug",
"title": "Crash on checkout",
"message": 'Tapping "Pay" on the cart screen crashes the app.',
"end_user_email": "[email protected]",
"context": {"app_version": "1.4.2", "os": "iOS 18.1"},
},
)Request body
The body must be JSON. Unknown fields are rejected, so only send the fields below.
| Field | Type | Required | Notes |
|---|---|---|---|
type | "bug" | "feature_request" | "suggestion" | yes | The kind of report. |
title | string (≤ 200) | conditional | Short summary. Provide title and/or message. |
message | string (≤ 5000) | conditional | Body text. Provide title and/or message. |
end_user_id | string (≤ 255) | no | Your identifier for the reporting user. |
end_user_email | string, email (≤ 320) | no | Reporter's email. |
end_user_name | string (≤ 255) | no | Reporter's name. |
context | object (≤ 16 KB serialized) | no | Diagnostic metadata: app version, OS, device, locale, etc. |
custom_data | object (≤ 16 KB serialized) | no | Any extra structured data you want to attach. |
attachments | array of Attachment (≤ 10) | no | Links to screenshots, logs, etc. |
Field rules
- You must include at least one of
titleormessage. contextandcustom_dataare free-form JSON objects. Usecontextfor diagnostic/environment data andcustom_datafor your own domain fields.
Attachment
| Field | Type | Required | Notes |
|---|---|---|---|
url | string, URL (≤ 2048) | yes | Publicly reachable file URL. |
file_name | string (≤ 255) | no | Display name. |
content_type | string (≤ 255) | no | MIME type, e.g. image/png. |
size_bytes | integer ≥ 0 | no | File size in bytes. |
Responses
| Status | Meaning |
|---|---|
201 | Report received. Body: { "id": number, "status": "received" }. |
400 | Body was not valid JSON. |
401 | API key missing, malformed, unknown, or revoked. |
422 | Validation failed. Body includes an issues array (see below). |
429 | Rate limit exceeded. Retry after the Retry-After header. |
A 422 response lists what went wrong:
{
"error": "Validation failed",
"issues": [{ "path": "message", "message": "message or title is required" }]
}Rate limits
The endpoint is rate limited per API key and per IP address using a fixed one-minute window:
- 60 requests/minute per API key
- 120 requests/minute per IP address
Every response includes the current limit state:
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 42
X-RateLimit-Reset: 37When you exceed a limit you get 429 Too Many Requests with a Retry-After
header (seconds). Back off until the window resets.
Don't retry 4xx responses (except 429) — they mean the request itself
needs fixing. Only retry 429 and 5xx, and respect Retry-After.
CORS
The endpoint allows cross-origin requests from any origin, so you can call it directly from browser JavaScript. Because keys are publishable and requests don't use cookies, this is safe — but treat the key as a public identifier, not a secret.