# OwnClimb Detection API

Turn a wall photo into scored hold polygons. Submit an image and take the
result the way that suits you: wait for it, poll for it, or stream it as it is
found. The [OpenAPI document](/api/v1/openapi) is the machine-readable contract
for the same reference.

Machine-readable contract: `/api/v1/openapi`. Raw markdown: `/docs.md`.

Base URL: `https://ownclimb.com/api/v1`

## Authentication

Send your `oc_` API key as `Authorization: Bearer <key>`, or as the `X-API-Key`
header.

## Quickstart

One request, one answer:

```bash
curl -X POST https://ownclimb.com/api/v1/detections/blocking \
  -H "Authorization: Bearer oc_your_key" \
  -F "image=@wall.jpg"
```

```json
{
  "id": "7fc…",
  "status": "done",
  "detail": "refined",
  "color": true,
  "stage": null,
  "image": { "width": 2048, "height": 1536 },
  "holds": [{ "polygon": [412, 233, 430, 251], "confidence": 0.96, "color": "#c0392b" }],
  "created_at": "2026-08-28T09:12:00.000Z",
  "finished_at": "2026-08-28T09:12:05.212Z",
  "model_time_ms": 5200,
  "cached": false,
  "error": null
}
```

## The request

Every endpoint below takes the same multipart form.

| Field      | Type        | Default   | Description                                                                       |
| ---------- | ----------- | --------- | --------------------------------------------------------------------------------- |
| `image`    | file        | —         | Wall photo (JPEG/PNG/WebP/HEIC, max 40 MB). Required.                             |
| `detail`   | string      | `refined` | `refined` for mask-accurate outlines, `raw` for the detector's own.               |
| `color`    | boolean     | `true`    | Sample the dominant colour inside each polygon.                                   |
| `cache`    | boolean     | `true`    | Whether this run may serve as the cached result for future identical submissions. |
| `metadata` | JSON string | —         | Optional object stored with this detection.                                       |

The API does not expose a run history or usage endpoint. Keep the detection id
returned by a submission when you need to read that individual detection; quota
availability is reported by submission responses.

### `detail`

`refined` is the default and what you want unless you have measured otherwise.
`raw` skips the outline-refinement pass: roughly twice as fast, with polygons
that follow the hold more loosely. Both return the same fields.

### `color`

Colour extraction samples the dominant colour inside each finished polygon and
returns it as `#rrggbb`. Turn it off with `color=false` and the `color` field is
absent from every hold.

### `cache`

Identical submissions are answered from the cache: a finished run for the same
image and request serves the next one without spending inference time. Submit
`cache=false` and your run is still answered — including from an earlier cached
run — but the row it creates is never considered as a cached result for anyone
else, so the next identical submission runs for real.

## The response

Every endpoint returns the same object, and a streaming endpoint returns a
sequence of them. A detection in progress and a finished one differ only in
their field values — never in their shape — so one parser handles both.

| Field           | Description                                                                  |
| --------------- | ---------------------------------------------------------------------------- |
| `id`            | The detection's uuid.                                                        |
| `status`        | `queued`, `running`, `done` or `failed`.                                     |
| `detail`        | The detail level this detection ran at.                                      |
| `color`         | Whether colours were sampled.                                                |
| `stage`         | What it is doing now, or `null` when it is finished or has not started.      |
| `image`         | `{ width, height }` — **the space `polygon` coordinates are in**. See below. |
| `holds`         | The detections so far. Grows while the run is in progress.                   |
| `created_at`    | When the detection was submitted.                                            |
| `finished_at`   | When it finished, or `null`.                                                 |
| `model_time_ms` | How long inference itself took, or `null` when that is unknown.              |
| `cached`        | Whether the result was reused from an identical earlier detection.           |
| `error`         | `{ code, message }` when `status` is `failed`, otherwise `null`.             |

A hold is `{ "polygon": [x0, y0, x1, y1, …], "confidence": 0.96, "color": "#c0392b" }`.
`color` is absent when the detection ran with `color=false`.

### Coordinates

**`polygon` coordinates are in the space of `image`, not of the file you
uploaded.** OwnClimb normalises an upload before running detection by resizing
its long edge to at most 4000px. The resize preserves the aspect ratio, so
neither dimension exceeds 4000px; images already under that cap are left
untouched. For example, a 4000×5000 upload returns an image of 3200×4000.
Scale by `yourWidth / image.width` to place holds on your original.

### Stages

While a detection runs, `stage` names what it is doing. Every stream starts with
two opening frames: `status: queued, stage: null`, then `status: running, stage:
null`. These arrive before any stage-specific progress frame. A `null` stage can
therefore mean that work is waiting to start as well as that the detection is
finished. **Which stages appear depends on the request**, so do not wait for one
a detection will never report:

| Request                    | Stages                                |
| -------------------------- | ------------------------------------- |
| `detail=refined` (default) | `detecting` → `refining` → `coloring` |
| `detail=raw`               | `detecting` → `coloring`              |
| `detail=raw&color=false`   | `detecting`                           |

## Endpoints

### POST /api/v1/detections/blocking

Submit an image and wait for the result.

Returns `200` with the finished detection. If it has not finished within 120
seconds you get `202` instead, carrying the detection's id and a `Location`
header — the work continues, so follow it with `GET /api/v1/detections/{id}`
rather than resubmitting.

| Status | Meaning                                            |
| :----: | -------------------------------------------------- |
| `200`  | The finished detection.                            |
| `202`  | Still running; follow the `Location` header.       |
| `400`  | `invalid_request` or `invalid_image`.              |
| `401`  | `unauthorized`.                                    |
| `402`  | `quota_exceeded` or `account_locked`.              |
| `413`  | `image_too_large`.                                 |
| `429`  | `rate_limited`; wait out the `retry-after` header. |

### POST /api/v1/detections/polling

Submit an image and return immediately.

Returns `202` with the detection's id and current state, plus a `Location`
header. Read `GET /api/v1/detections/{id}` until `status` is `done` or `failed`.

```bash
curl -X POST https://ownclimb.com/api/v1/detections/polling \
  -H "Authorization: Bearer oc_your_key" \
  -F "image=@wall.jpg" \
  -F "detail=raw"
# → 202 { "id": "7fc…", "status": "queued", "holds": [], … }
```

### POST /api/v1/detections/streaming

Submit an image and stream the result as it is found.

Each item is a complete detection object; `holds` grows as stages land. The
stream ends at the item whose `status` is `done` or `failed`, or after 150
seconds if the detection never finishes.

The default response has `Content-Type: application/x-ndjson`. It is newline-
delimited JSON: each complete detection is one JSON object followed by a newline.
Network reads are not frame boundaries; a read may split an object or contain
several objects, so buffer incoming text until a newline before parsing.

Newline-delimited JSON by default — one detection per line, so a client needs no
event-stream parser:

```bash
curl -N -X POST https://ownclimb.com/api/v1/detections/streaming \
  -H "Authorization: Bearer oc_your_key" \
  -F "image=@wall.jpg"
```

```json
{"id":"7fc…","status":"running","stage":"detecting","holds":[…],…}
{"id":"7fc…","status":"running","stage":"refining","holds":[…],…}
{"id":"7fc…","status":"done","stage":null,"holds":[…],…}
```

Send `Accept: text/event-stream` for `Content-Type: text/event-stream` and
server-sent events instead, where every item arrives as a `state` event. Network
reads can also split or combine event data, so buffer until the blank line that
ends an event. There is only ever one event name: the last item is the one that
says `done` or `failed`.

### GET /api/v1/detections/{id}

Get a detection's current state.

| Status | Meaning                                                     |
| :----: | ----------------------------------------------------------- |
| `200`  | The detection; `holds` is complete once `status` is `done`. |
| `401`  | `unauthorized`.                                             |
| `404`  | `not_found`.                                                |

## Caching

An identical image submitted at the same `detail` returns the earlier result
with `cached: true`, and does not spend your monthly allowance. `detail=raw` and
`detail=refined` are cached separately, because their polygons genuinely differ.
A detection asked for without colours can be answered from a cached one that has
them; the colours are simply left out.

## Error codes

Errors come back as `{ "error": { "code", "message" } }`, with an optional
`details` object on validation failures.

| Code                  | Status | Meaning                                              |
| --------------------- | :----: | ---------------------------------------------------- |
| `invalid_request`     | `400`  | The request or its fields are not valid.             |
| `invalid_image`       | `400`  | The upload does not decode as an image.              |
| `image_too_large`     | `413`  | The image exceeds the size limit.                    |
| `unauthorized`        | `401`  | Missing or invalid API key.                          |
| `not_found`           | `404`  | No such detection, or it belongs to another account. |
| `quota_exceeded`      | `402`  | The monthly inference quota is used up.              |
| `account_locked`      | `402`  | The subscription is inactive.                        |
| `rate_limited`        | `429`  | Too many requests; retry after the delay.            |
| `inference_failed`    | `500`  | The detection failed.                                |
| `storage_unavailable` | `503`  | Image storage was unreachable.                       |
