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.

MethodPathScopeRate classCreditsRequestResponse
GET/api/v1/meany keyread0ok: true, version: "2026-09-01", org: { id, name }, key: { id, prefix, name, mode, scopes, last_used_at, … }, rate: { class, limit, remaining }
GET/api/v1/projectsprojects:readread0querycursor?: string, limit?: integerok: true, items: object[], next_cursor: string | null
POST/api/v1/projectsprojects:writewrite0bodyname: string, address?: string, lat?: number, lng?: number, client_name?: string, client_email?: stringok: true, project: { id, name, address, status, anchor, client_name, … }, sandbox?: true
GET/api/v1/projects/:idprojects:readread0ok: true, project: { id, name, address, status, anchor, client_name, … }, sandbox?: true
POST/api/v1/projects/:id/shotsshots:writewrite0bodyoriginal_filename: string, mime?: string, bytes: integer, sha256: string, width?: integer, height?: integer, pose?: objectok: 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/:idshots:readread0ok: true, shot: { id, project_id, status, original_filename, mime, bytes, … }, sandbox?: true
POST/api/v1/shots/:id/exportexports:writespendthe pinned-photo price on the first successful export of a shot; 0 on every later export of that shotbodyformat?: "jpg" | "png" | "webp" | "tiff", resolution?: "original" | "4k" | "2k" | "web", brand_kit_id?: string, include_distances?: booleanHeader required: Idempotency-Keyok: 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/:idexports:readread0ok: true, export: { id, shot_id, job_id, status, format, width, … }, download_url: string | null, download_expires_at: string | null
GET/api/v1/jobs/:idany keyread0ok: true, job: { id, kind, state, ref_type, ref_id, attempts, … }

Scopes

ScopeLets a keyRoutes today
projects:readSee the projects and their details.GET /api/v1/projectsGET /api/v1/projects/:id
projects:writeCreate projects.POST /api/v1/projects
shots:readSee the photos in a project and whether each upload finished.GET /api/v1/shots/:id
shots:writeAdd photos to a project.POST /api/v1/projects/:id/shots
exports:readSee the finished pinned photos and get their download links.GET /api/v1/exports/:id
exports:writeMake pinned photos. Spends credits.POST /api/v1/shots/:id/export
ai:writeMake edits such as relight and declutter. Spends credits.none yet
delivery:writeSend client pages.none yet
webhooks:manageTell other software when something happens here.none yet
brand_kits:readSee 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.

ClassPer minuteBurstIf the limiter is downRoutes
read600600allows (fail-open)6
write120120allows (fail-open)2
spend6060refuses (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> } }
CodeStatus
unsupported_version400
invalid_body400
invalid_query400
unauthenticated401
api_key_auth_disabled401
invalid_api_key401
insufficient_scope403
not_found404
insufficient_credits402
key_cap402
key_revoked401
key_expired401
too_large413
unsupported_mime415
export_refused409
export_failed409
in_progress409
conflict409
rate_limited429
not_configured503
rate_limit_unavailable503
internal500

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 → ready after the PUT) — session-only; a key cannot finish an upload it started. Next slice.
    • Batch export; brand-kit reads. (ai:write, delivery:write, webhooks:manage shipped in slice 2 — see below.)
    • pf_test_ keys parse but mint only as pf_live_ today (no sandbox ledger).

    Something missing? Contact support.

    You need to be signed in. Sign in and open Support to send us a message. We answer in the app.

    Developer docs · Pin the Lot