Developer docs
The public API, version 2026-09-01. Every table on this page is generated from the same contract the app runs on, so it cannot say one thing while the server does another.
9 of 21 routes in the table below · 10 scopes · 22 error codes · generated at build
Overview
A key belongs to one workspace and can only reach that workspace. Make keys in Settings → API keys (owners and admins). Every request is rate limited. Credits move over a key only through the 3 routes that spend them — the single and batch pinned-photo routes and the edit route, listed under The money path below — and each is charged exactly as in the app: only on success, and never twice for the same photo or edit.
Billing and key management are never key-accessible: they stay in the app, behind a signed-in session.
Authentication
Send the key as a bearer token. The plaintext is shown once, when the key is made; only a one-way digest is stored, so a lost key is replaced, never recovered. A revoked key answers invalid_api_key.
Authorization: Bearer <key>
Key shape (regular expression): ^pf_(live|test)_[a-z2-7]{8}_[a-z2-7]{32}$
Versioning
Send accept-version with a served version. Leave it out and you get the current one, 2026-09-01. An unknown value is refused with unsupported_version before authentication. Every response carries api-version. One version is served today. A breaking change adds a newer date and keeps the old shape for six months.
Routes
Scope is what the key must carry. Rate class is taken from both the key’s bucket and the workspace’s. Fields marked ? are optional.
| Method | Path | Scope | Rate class | Credits | Request | Response |
|---|---|---|---|---|---|---|
| GET | /api/v1/me | any key | read | 0 | — | ok: true, version: "2026-09-01", org: { id, name }, key: { id, prefix, name, mode, scopes, last_used_at, … }, rate: { class, limit, remaining } |
| GET | /api/v1/projects | projects:read | read | 0 | querycursor?: string, limit?: integer | ok: true, items: object[], next_cursor: string | null |
| POST | /api/v1/projects | projects:write | write | 0 | bodyname: string, address?: string, lat?: number, lng?: number, client_name?: string, client_email?: string | ok: true, project: { id, name, address, status, anchor, client_name, … }, sandbox?: true |
| GET | /api/v1/projects/:id | projects:read | read | 0 | — | ok: true, project: { id, name, address, status, anchor, client_name, … }, sandbox?: true |
| POST | /api/v1/projects/:id/shots | shots:write | write | 0 | bodyoriginal_filename: string, mime?: string, bytes: integer, sha256: string, width?: integer, height?: integer, pose?: object | ok: true, shot: { id, project_id, status, original_filename, mime, bytes, … }, upload: { url, method, headers, provider, key } | null, preview: { url, method, headers, provider, key } | null, storage_error: string | null, sandbox?: true |
| GET | /api/v1/shots/:id | shots:read | read | 0 | — | ok: true, shot: { id, project_id, status, original_filename, mime, bytes, … }, sandbox?: true |
| POST | /api/v1/shots/:id/export | exports:write | spend | the pinned-photo price on the first successful export of a shot; 0 on every later export of that shot | bodyformat?: "jpg" | "png" | "webp" | "tiff", resolution?: "original" | "4k" | "2k" | "web", brand_kit_id?: string, include_distances?: booleanHeader required: Idempotency-Key | ok: true, job_id: string, export_id: string, state: "queued" | "running" | "succeeded" | "failed" | "cancelled", reservation: { credits }, credits_charged: integer, replayed: boolean, eta_seconds: integer, idempotency_key: string, download_url: string | null, download_expires_at: string | null, sandbox?: true |
| GET | /api/v1/exports/:id | exports:read | read | 0 | — | ok: true, export: { id, shot_id, job_id, status, format, width, … }, download_url: string | null, download_expires_at: string | null |
| GET | /api/v1/jobs/:id | any key | read | 0 | — | ok: true, job: { id, kind, state, ref_type, ref_id, attempts, … } |
Scopes
| Scope | Lets a key | Routes today |
|---|---|---|
| projects:read | See the projects and their details. | GET /api/v1/projectsGET /api/v1/projects/:id |
| projects:write | Create projects. | POST /api/v1/projects |
| shots:read | See the photos in a project and whether each upload finished. | GET /api/v1/shots/:id |
| shots:write | Add photos to a project. | POST /api/v1/projects/:id/shots |
| exports:read | See the finished pinned photos and get their download links. | GET /api/v1/exports/:id |
| exports:write | Make pinned photos. Spends credits. | POST /api/v1/shots/:id/export |
| ai:write | Make edits such as relight and declutter. Spends credits. | none yet |
| delivery:write | Send client pages. | none yet |
| webhooks:manage | Tell other software when something happens here. | none yet |
| brand_kits:read | See your branding: the colours, font and label style. | none yet |
Rate limits
Two buckets per call, same class: the key’s, then the workspace’s. A refusal answers rate_limited with Retry-After. A fail-closed class refuses with rate_limit_unavailable when the limiter is unreachable, rather than running unmetered.
| Class | Per minute | Burst | If the limiter is down | Routes |
|---|---|---|---|---|
| read | 600 | 600 | allows (fail-open) | 6 |
| write | 120 | 120 | allows (fail-open) | 2 |
| spend | 60 | 60 | refuses (fail-closed) | 1 |
Errors
One envelope, one status per code. The message is a plain sentence and never carries schema internals. retry_after (seconds) is present only on the two limiter codes.
{ "error": { "code": "<code>", "message": "<plain sentence>", "retry_after": <seconds, limiter codes only> } }| Code | Status |
|---|---|
| unsupported_version | 400 |
| invalid_body | 400 |
| invalid_query | 400 |
| unauthenticated | 401 |
| api_key_auth_disabled | 401 |
| invalid_api_key | 401 |
| insufficient_scope | 403 |
| not_found | 404 |
| insufficient_credits | 402 |
| key_cap | 402 |
| key_revoked | 401 |
| key_expired | 401 |
| too_large | 413 |
| unsupported_mime | 415 |
| export_refused | 409 |
| export_failed | 409 |
| in_progress | 409 |
| conflict | 409 |
| rate_limited | 429 |
| not_configured | 503 |
| rate_limit_unavailable | 503 |
| internal | 500 |
Pagination
List routes take limit (1 to 100, default 50) and an opaque cursor. Pass next_cursor back verbatim; null means the end. A cursor you made yourself is refused with invalid_query.
The money path
These 3 routes spend credits: POST /api/v1/shots/:id/export, POST /api/v1/shots/:id/ai, POST /api/v1/projects/:id/exports. It runs the same code as the app’s download button: credits are held, the photo is made, and only then are they taken. Any failure releases the hold. A second export of the same photo is free.
- The Idempotency-Key header is required. Replaying the same key answers the original result with replayed: true and credits_charged: 0 — no second charge, job or file.
- The finished file is stored and a signed download link is handed out for 15 minutes at a time; fetch a fresh one from the export route.
- Object storage must be configured on the deployment; otherwise the request is refused with not_configured before any credit moves.
curl examples
From the API specification, verbatim. The base address is wherever your workspace runs. We are live.
BASE=https://app.pinthelot.com # or http://localhost:3111 on the local stage
KEY=pf_live_…
# who am I + remaining read tokens
curl -s $BASE/api/v1/me -H "Authorization: Bearer $KEY" -H "Accept-Version: 2026-09-01"
# create a project with a known anchor (lat/lng travel together)
curl -s -X POST $BASE/api/v1/projects -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"name":"12 Pier Street","address":"12 Pier Street, Altona VIC 3018","lat":-37.8637,"lng":144.8302}'
# list, then page
curl -s "$BASE/api/v1/projects?limit=20" -H "Authorization: Bearer $KEY"
curl -s "$BASE/api/v1/projects?limit=20&cursor=<next_cursor>" -H "Authorization: Bearer $KEY"
# register a shot (C-04 metadata → presigned PUT for the original), then upload the file to `upload.url`
curl -s -X POST $BASE/api/v1/projects/<project_id>/shots -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
-d '{"original_filename":"DJI_0781.JPG","mime":"image/jpeg","bytes":18344201,"pose":{"gps_lat":-37.8637,"gps_lng":144.8302,"rel_altitude_m":82.4,"flight_yaw_deg":58.9,"gimbal_yaw_deg":58.3,"gimbal_pitch_deg":-20.1,"image_w":8064,"image_h":4536}}'
curl -s -X PUT "<upload.url>" -H "content-type: image/jpeg" --data-binary @DJI_0781.JPG
# then mark it ready through the app (PATCH /api/shots/:id is session-only today)
# export — the money path; the Idempotency-Key makes a retry free
curl -s -X POST $BASE/api/v1/shots/<shot_id>/export -H "Authorization: Bearer $KEY" \
-H "Idempotency-Key: order-4711" -H "Content-Type: application/json" -d '{"include_distances":true}'
# fetch the signed download later (15 min)
curl -s $BASE/api/v1/exports/<export_id> -H "Authorization: Bearer $KEY"
curl -s $BASE/api/v1/jobs/<job_id> -H "Authorization: Bearer $KEY"Not in v1 yet
An honest list. Scopes a key may carry that no route uses yet:
PATCH /shots/:id(status →readyafter the PUT) — session-only; a key cannot finish an upload it started. Next slice.- Batch export; brand-kit reads. (
ai:write,delivery:write,webhooks:manageshipped in slice 2 — see below.) pf_test_keys parse but mint only aspf_live_today (no sandbox ledger).