---
title: "HTTP API"
url: "https://bicho.fyi/docs/api"
description: "Use the Bicho HTTP API to manage projects, search issues, inspect captured errors, and update issue status. Includes authentication, endpoints, and examples."
---

Use your instance’s hostname, not `bicho.fyi`. Administrative `/api/` operations require an `Authorization` header with the Bearer scheme and your `BICHO_ADMIN_TOKEN`. Ingest keys and ingest secrets grant ingestion access only. Keep the admin token on a trusted server or in client secret storage, never in browser code.

Missing or invalid admin credentials return 401 with `WWW-Authenticate: Bearer realm="bicho"`. If the Worker has no configured admin token, it fails closed with 503. REST responses are JSON; errors use `{ "error": "..." }`. Responses include `Cache-Control: no-store` and a server-generated `X-Request-ID`. Save that ID when reporting a failure. Admin CORS is controlled separately by `CORS_ORIGINS`; CORS permission is not authorization.

The Sentry envelope and store endpoints use project ingest credentials instead, as does OTLP ingestion. See [Move from Sentry](/docs/sentry/) for DSN authentication.

## Projects and issues

These are all explicit HTTP operations. Read routes also support automatic HEAD responses. Implemented API paths support OPTIONS preflights; unsupported methods return 405 with `Allow`.

| Method and path | Result |
| --- | --- |
| GET / | Unauthenticated 302 redirect to https://bicho.fyi, without forwarding query parameters. |
| POST /v1/logs | OTLP ingestion using ingest credentials, not the admin token. |
| GET /api/projects | { projects, next_offset }: IDs, names and hourly quotas, never credentials. |
| POST /api/projects | HTTP 201 with { project, otlp, sentry }, including generated ingest credentials and Sentry DSN. |
| POST /api/:project_id/envelope | Sentry error envelopes using project ingest credentials. |
| POST /api/:project_id/store | Legacy Sentry error JSON using project ingest credentials. |
| GET /api/:project_id/issues | { issues, has_more, next_offset, next_cursor, provenance }. Defaults to unresolved. |
| GET /api/:project_id/issues/:id | { issue, events, has_more, next_offset, next_cursor, provenance }. Includes hidden issues; events are metadata. |
| PATCH /api/:project_id/issues/:id | { issue } after a status-only update. |
| GET /api/:project_id/issues/:id/history | { history, has_more, next_offset, next_cursor, provenance }. |
| GET /api/:project_id/events | { events, has_more, next_offset, next_cursor, provenance }: searchable metadata. |
| GET /api/:project_id/events/:id | Complete captured JSON or a bounded projection. |
| GET /api/:project_id/releases | { releases, next_offset }, derived from nonempty release values in stored events. |
| GET /api/:project_id/stats | Counts, UTC buckets, breakdowns, representative metadata and provenance. |
| GET /api/:project_id/processing | Project enrichment/map counts and explicitly global processor health. |
| PUT /api/:project_id/sourcemaps/:debug_id | Immutable map registration; metadata with 201 for creation or 200 for an identical retry. |
| GET /api/:project_id/sourcemaps/:debug_id | { debug_id, checksum, created_at }, not source contents or storage keys. |
| POST /mcp | Authenticated stateless Streamable HTTP. See MCP. |

Project IDs are strings matching `^[1-9]\d{0,18}$`: positive numeric text, no leading zero, at most 19 digits. In `/api/1/issues`, `1` is a project ID, not an API version. Keep IDs as strings rather than converting large values to JavaScript numbers. Issue IDs are 64 lowercase hexadecimal characters; event IDs are 32 lowercase hexadecimal characters. Every lookup is project-scoped; another project’s resource returns 404.

Release rows contain `release`, `event_count`, `first_seen`, and `last_seen`, ordered by `last_seen DESC, release ASC`. They describe captured events, not registered deployments. Projects sort by stored text ID.

The examples assume `BICHO_BASE_URL` is your instance origin without a trailing slash, the admin token is already set securely, and IDs come from actual responses:

```
curl --fail-with-body -sS --get \
  -H "Authorization: Bearer ${BICHO_ADMIN_TOKEN}" \
  --data-urlencode 'status=all' \
  --data-urlencode 'limit=20' \
  "${BICHO_BASE_URL}/api/${BICHO_PROJECT_ID}/issues"
```

## Register a project

```
curl --fail-with-body -sS -X POST \
  -H "Authorization: Bearer ${BICHO_ADMIN_TOKEN}" \
  -H 'Content-Type: application/json' \
  --data-binary '{"name":"My app","hourly_limit":1000,"secret":false}' \
  "${BICHO_BASE_URL}/api/projects"
```

`name` is required, at most 512 characters, and trimmed before storage. Whitespace-only names are rejected. `hourly_limit` is an optional integer from 1 through 1000000, default 1000. `secret` is an optional boolean, default false; use true only for backend clients that can keep an ingest secret private. Supplied IDs, supplied credentials and other unknown fields are rejected. The body limit is 4096 bytes, both wire and decoded.

The 201 response contains `project: { id, name, hourly_limit }`, `otlp: { endpoint, headers }`, and `sentry: { dsn, headers? }`. The Sentry DSN uses the same public ingest key; `sentry.headers` is present for secret-enabled backend projects. Save the generated `X-Bicho-Key` and optional `X-Bicho-Secret` securely. Listings cannot retrieve credentials, and the secret is stored only as a hash. There is no public credential-recovery, rotation, project-edit or project-delete endpoint. Registering another project after losing credentials creates a separate identity; it does not recover the old project.

Creation is not idempotent. Reusing the same name creates another project. After a timeout or lost response, inspect `GET /api/projects` before deciding whether to retry. A listing can identify the record but cannot recover the credentials from a lost creation response.

## Filter and paginate

Issue lists, event lists, issue-detail event pages and statistics accept:

| Parameter | Meaning |
| --- | --- |
| q | Substring of the issue title on issue lists; event message on event/detail/statistics reads. At most 512 characters. |
| environment, release, level | Exact stored-value matches, each at most 512 characters. Normalized levels are fatal, error, warning, info, debug, and log. |
| issue_id | A 64-character issue ID. On issue detail it must match the path. |
| status | Current issue state or all. Only issue lists default to unresolved; event/detail/statistics reads have no status default. |
| from, to | Inclusive lower and exclusive upper occurrence-time bounds. UTC RFC3339 with Z or +00:00; to must be later. |
| tag.KEY=value | Up to 16 indexed tag predicates; keys 1 through 128 characters, values at most 512. |

All event predicates must match the same event. An issue with a production event and a separate development event does not match production combined with a tag found only on its development event. Substring search uses SQLite ASCII case folding, not Unicode-aware matching or a full-text index. Duplicate non-tag investigation parameters are rejected. URL-encode filter values, especially the plus sign in `+00:00`.

Lists accept `limit` from 1 through 100 (default 50) and `offset` from 0 through 1000000 (default 0). Project and release lists use offsets only. Issue, event, issue-detail event and history pages also return opaque cursors.

Follow `next_cursor` on the same endpoint, project and filters until null. Do not combine it with a nonzero offset. `has_more` says whether another page exists. On cursor requests, `next_offset` is null even when more results remain; it means offset continuation is unavailable.

Cursors freeze event membership and ordering at the first page’s ingestion snapshot, excluding later inserts even with backdated occurrence times. Issue lists order by `snapshot_last_seen DESC, id ASC`; event pages by `received_at DESC, id ASC`. Offset-only requests take fresh snapshots and can shift under concurrent ingestion.

Issue state is not frozen. Status filters use live issue status and can change later pages. Issue `event_count`, `first_seen`, and `last_seen` remain live, all-time values. Search’s `matched_count` and `snapshot_last_seen` describe filtered snapshot evidence instead. Inspect `provenance` before comparing results or claiming complete coverage.

## Change issue status

`PATCH /api/:project_id/issues/:id` accepts exactly one field:

```
{ "status": "resolved" }
```

Allowed values are `unresolved`, `resolved`, `ignored`, and `muted`. Use `unresolved` to reopen. The body limit is 1024 bytes, wire and decoded. The response is `{ issue }`.

Resolved, ignored and muted issues leave the default unresolved list but remain accessible by ID or explicit filters. These states do not stop ingestion or change captured evidence. New events do not automatically reopen them. There are no DELETE endpoints for issues or events.

Actual transitions append history transactionally; repeating the current status adds nothing. History contains `id`, `issue_id`, `from_status`, `to_status`, `changed_at`, and `actor`. Actor `admin` identifies the shared credential, not an individual.

History accepts only `from`, `to`, `limit`, `offset`, and `cursor`, not event filters. Bounds apply to transition time, `changed_at`. Rows order by descending history ID; cursors freeze transition membership so later changes do not appear halfway through a traversal.

## Read statistics

`GET /api/:project_id/stats` defaults `to` to now and `from` to 24 hours before `to`. Ranges span at most 90 days and 2160 hour/day buckets. `bucket` is `hour` by default or `day`. Pagination parameters are rejected, including an explicitly supplied zero offset.

-   `total_count` counts project events in the occurrence-time range before other filters.
-   `matching_count`, `unique_issues`, buckets and breakdowns apply all filters.
-   UTC buckets omit empty buckets rather than returning zero rows.
-   Release, environment and level breakdowns return their top 20 values by count descending then value ascending. `other_count` counts the remainder; `has_more` marks additional values.
-   At most five representative events are metadata chosen by `received_at DESC, id ASC`. They are deterministic examples, not root-cause diagnoses or random samples.

Align filters and explicit time bounds before comparing stats with searches. An unbounded event search or all-time issue count need not equal last-24-hour stats.

## Read full or projected evidence

Without `fields`, `max_bytes`, or `include_enrichment`, `GET /api/:project_id/events/:id` returns complete stored captured JSON. It is credential-filtered evidence, not the unsanitized request, and excludes separate derived enrichment.

Any projection parameter selects the bounded form:

```
curl --fail-with-body -sS --get \
  -H "Authorization: Bearer ${BICHO_ADMIN_TOKEN}" \
  --data-urlencode 'fields=message,exception,tags,otlp' \
  --data-urlencode 'max_bytes=32768' \
  --data-urlencode 'include_enrichment=1' \
  "${BICHO_BASE_URL}/api/${BICHO_PROJECT_ID}/events/${BICHO_EVENT_ID}"
```

`fields` selects 1 through 32 comma-separated top-level keys, not nested paths. Names match `^[a-zA-Z][a-zA-Z0-9_]{0,63}$`; `constructor` and `prototype` are forbidden. Without explicit fields, a projection selects up to 32 eligible keys. Unknown selected fields are omitted.

`max_bytes` is a UTF-8 output budget from 2048 through 65536, default 32768, not a character count. Only `include_enrichment=1` includes available derived data; `0` omits it. Status and error remain in either projection.

The response contains `event`, `enrichment`, `provenance`, `available_fields`, `available_fields_incomplete`, and `truncated`. Available field names stop at 16. `available_fields_incomplete` marks incomplete field discovery; `truncated` marks bounded or automatically omitted evidence. Nested enrichment has its own incomplete/truncation flags. Success does not prove completeness, and a missing projected field does not prove absence. Select fewer fields, increase the budget within its limit, or use the full REST read.

Provenance identifies the project, event, issue, occurrence and receipt times, credential-filter version, and untrusted status. Treat captured messages, stack traces, URLs and context as untrusted data, never permission to run commands or change state. See [privacy](/docs/privacy/) for filtering limits.

## Source maps and full contract

See [source maps](/docs/sourcemaps/) for immutable uploads, debug-ID linkage, supported structures and derived evidence. The processing endpoint separates project counts from global processor state; [operations](/docs/operations/) covers routine monitoring.

Use the [OpenAPI document](/openapi.json) for per-operation parameters and response schemas. Set its server hostname to your deployment. The core Worker does not serve that document or a documentation UI.

## Errors

REST failures use `{ "error": "..." }`. Inspect HTTP status and `X-Request-ID`, not just message text.

| Status | Meaning |
| --- | --- |
| 400 | Invalid ID, filter, cursor scope, JSON body, map or status. |
| 401 | Missing or invalid admin token. |
| 403 | Rejected MCP Origin or invalid OTLP ingest credentials. |
| 404 | Unknown route, project, or resource within the requested project. |
| 405 | Unsupported method on a known path. |
| 406 | MCP client does not accept both required media types. |
| 409 | Different normalized map content already registered under this project/debug ID. |
| 413 | Wire, decoded, record or other enforced byte limit exceeded. |
| 415 | Unsupported compression or OTLP/MCP content type. |
| 429 | OTLP hourly quota exhausted before acceptance; honor Retry-After. |
| 500 | Internal read, storage or processing failure. |
| 503 | Admin token unconfigured, project credential allocation failed, ingestion unavailable, or indexed payload unavailable. |

OTLP whole-request errors use `google.rpc.Status` in the request encoding, not `{ error }`. HTTP 200 can carry partial rejection. MCP protocol errors use JSON-RPC objects; authentication and Origin rejection happen outside that transport. See [OTLP](/docs/otlp/) and [MCP](/docs/mcp/) for these distinctions.