OwnClimb Detection API
View as markdownTurn 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 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:
curl -X POST https://ownclimb.com/api/v1/detections/blocking \
-H "Authorization: Bearer oc_your_key" \
-F "image=@wall.jpg"{
"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.
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:
curl -N -X POST https://ownclimb.com/api/v1/detections/streaming \
-H "Authorization: Bearer oc_your_key" \
-F "image=@wall.jpg"{"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. |