IntelliPicHub HTTP API
Living reference for third-party / cron-job consumers of the IntelliPicHub backend. Every endpoint that carries
@RequireScopeon the server should have a matching section here. When you add a new scoped endpoint, add it in the right section with a request shape, a response shape, and a curl example. If a scope is added or renamed, update the Scopes table.
Base URL https://<host>/api · local dev: http://localhost:8123/api
Status: V1 — covers picture ingestion. Future revisions add picture read APIs, search, and notifications. Scope strings are immutable once shipped — see Stability guarantees below.
Table of contents
- Authentication
- Scopes
- Response envelope
- Error codes
- API key management
- Picture upload (
picture:upload) - Stability guarantees
- Roadmap
Authentication
There are two paths into the API. Endpoints don't care which one a request took as long as the resolved user has the required permissions.
| Path | Where it comes from | Lifetime | Use it for |
|---|---|---|---|
| Supabase JWT | supabase.auth browser session | 1 hour, refreshable | Browser UI, anything interactive |
| API key | POST /auth/api-keys (this doc) | Until revoked / expired | Cron jobs, Workers, server-to-server |
Both go in the same header:
Authorization: Bearer <token>The server's ApiKeyAuthFilter runs first and recognises the API key prefix iph_live_ — anything else is handed off to JwtAuthFilter. So mixing both kinds of clients against the same endpoint just works.
API key acts as the user who created it
A key's effective permissions = (its owner's role permissions) ∩ (its granted scopes). A regular user's picture:upload key can only upload into spaces that user has PICTURE_UPLOAD permission on; it cannot reach an admin-only endpoint even if the scope string somehow matched. The admin:* scope is admin-grantable only.
Token format
iph_live_<32 random chars>The 32-char body is drawn from a 56-symbol alphabet that excludes 0, O, 1, l, I (so a copy-paste error doesn't silently produce a different valid token). Roughly 186 bits of entropy.
Only SHA-256(token) is stored — there's no recovery flow. If you lose a token, revoke it and create a new one. The full plaintext is returned exactly once, in the response to POST /auth/api-keys. After that the API will only ever return the 13-char display prefix (e.g. iph_live_a8K9x).
Scopes
| Scope | Who can grant | What it covers |
|---|---|---|
picture:upload | Any user | The R2 two-stage upload flow + admin batch URL ingestion |
admin:* | Admin user only | Resource-prefix wildcard — every admin endpoint (admin trash purge, admin batch import diagnostics, admin user ban, etc.). Not a super wildcard; does not cover picture:upload. |
The wildcard rule used at runtime: a granted scope of a:b:* matches any required a:b:<anything>; granted * matches everything. So if you ever need a true super-key, grant * (currently not in the public catalog — gate it carefully).
The set of scopes a user can grant on POST /auth/api-keys is filtered by their role. Hit GET /auth/api-keys/available-scopes to find out exactly what a given caller may request.
Response envelope
Every JSON endpoint wraps its payload in:
{
"code": 0,
"data": { /* endpoint-specific */ },
"message": "ok"
}code: 0 means success. Any non-zero code is a business error and data is null. See Error codes below. SSE endpoints (/picture/upload/batch/selected) are the exception — they stream events directly, not the envelope.
Error codes
| Code | Meaning | Typical trigger |
|---|---|---|
0 | OK | — |
40000 | Invalid request parameters | Missing/malformed field, body validation failure |
40100 | Not logged in | Missing/wrong/revoked/expired API key. All four cases return the same error so a caller can't enumerate which one happened. |
40101 | No permission / missing scope | Authenticated, but the key doesn't carry the scope the endpoint requires. Example: "API key missing required scope: picture:upload" |
40300 | Access forbidden | User-level permission check failed (e.g. PICTURE_UPLOAD permission on a space the caller doesn't belong to). |
42301 | Embedding not ready | Picture too new for /similar/list. Frontend falls back to tag-based recommendation. |
50000 | System internal exception | Server bug or downstream outage. |
HTTP status is the standard 200 for success and 4xx/5xx for the matching error class. Always check code in the body — a 200 with code != 0 is a business error.
API key management
These five endpoints live under /auth/api-keys and only require a logged-in user (Supabase JWT works; an API key with the right scope would too, but there's no scope for self-management in V1 so use JWT).
POST /auth/api-keys — create
Request:
{
"name": "ingest-worker-2026-05",
"scopes": ["picture:upload"],
"description": "Cloudflare Worker cron, Wikimedia CC0 ingest",
"expiresInDays": 365
}| Field | Type | Required | Notes |
|---|---|---|---|
name | string | yes | 1–255 chars, used in UI listing |
scopes | string[] | yes | Each must appear in the catalog AND be grantable by the caller |
description | string | no | Free-form admin note |
expiresInDays | int | no | Omit / 0 = never expires |
Response (code: 0):
{
"data": {
"plaintext": "iph_live_...",
"key": {
"id": "1234567890",
"name": "ingest-worker-2026-05",
"prefix": "iph_live_a8K9",
"scopes": ["picture:upload"],
"expiresAt": "2027-05-04T00:00:00Z",
"revokedAt": null,
"lastUsedAt": null,
"lastUsedIp": null,
"totalRequests": 0,
"createTime": "2026-05-04T13:02:11Z",
"description": "Cloudflare Worker cron, Wikimedia CC0 ingest"
}
}
}plaintext is shown once. Save it now.
curl:
curl -X POST https://<host>/api/auth/api-keys \
-H "Authorization: Bearer <jwt>" \
-H "Content-Type: application/json" \
-d '{"name":"ingest-worker-2026-05","scopes":["picture:upload"]}'GET /auth/api-keys — list
Lists the caller's own keys (revoked ones included, with revokedAt set). Paginated.
GET /auth/api-keys?current=1&pageSize=20Response shape: standard MyBatis-Plus Page<ApiKeyVO> — records[], total, current, size. keyHash is never returned.
POST /auth/api-keys/{id}/revoke — revoke
Idempotent. A second call against an already-revoked key still returns true; the server doesn't differentiate "already revoked" from "just revoked now" so a curious caller can't probe state. A revoked key is rejected by the next request that uses it (returns 40100).
curl -X POST https://<host>/api/auth/api-keys/1234567890/revoke \
-H "Authorization: Bearer <jwt>"POST /auth/api-keys/update — update metadata
Only name and description are mutable. scopes is deliberately immutable — if you need a different scope set, create a new key and revoke the old one. This prevents silent permission creep.
{
"id": "1234567890",
"name": "ingest-worker-rotated",
"description": "rotated 2026-05-04, original token compromised"
}GET /auth/api-keys/available-scopes — catalog
Returns the scopes the caller may grant. Frontend uses this to render the create-key dialog. Use it in scripts when you need to discover the current set programmatically — the catalog is intended to grow.
{
"data": [
{
"value": "picture:upload",
"label": "Upload pictures",
"description": "Upload originals via the R2 two-stage flow + admin batch URL ingestion.",
"requiredRole": "user"
}
]
}Picture upload (picture:upload)
These three endpoints all require an API key that carries picture:upload, AND the resolved user must have PICTURE_UPLOAD permission on the target space (gallery target = no space check). The key clamps to the user, the user clamps to the space — both gates apply.
POST /picture/upload/r2/check — stage 1
You hash the file locally, the server probes for dedupe, and either hands back an existing blobId (skip the upload) or a presigned R2 PUT URL.
Request:
{
"sha256": "<64-char hex of the original file>",
"size": 524288,
"ext": "jpg",
"contentType": "image/jpeg",
"spaceId": null
}Response on dedupe hit:
{
"data": {
"duplicate": true,
"blobId": "987",
"stagingKey": null,
"putUrl": null,
"thumb": { "stagingKey": "...", "putUrl": "..." },
"preview": { "stagingKey": "...", "putUrl": "..." }
}
}Response on fresh upload:
{
"data": {
"duplicate": false,
"blobId": null,
"stagingKey": "staging/<uuid>.jpg",
"putUrl": "https://<r2-host>/staging/<uuid>.jpg?X-Amz-Signature=...",
"thumb": { "stagingKey": "...", "putUrl": "..." },
"preview": { "stagingKey": "...", "putUrl": "..." }
}
}Then PUT the bytes directly to the presigned URL(s) and call finalize.
POST /picture/upload/r2/finalize — stage 2
Promotes staging objects to permanent, creates the picture row, bumps ref_count on the blob, and returns the picture VO. Transactional.
Required fields when the original was a fresh upload:
{
"sha256": "...",
"stagingKey": "<from check response>",
"size": 524288,
"format": "JPEG",
"ext": "jpg",
"thumbKey": "<from check>",
"previewKey": "<from check>",
"embeddingKey": null,
"spaceId": null,
"name": "Mt. Fuji at sunrise",
"introduction": "Wikimedia Commons, CC0",
"category": "landscape"
}On a dedupe hit, omit stagingKey / format / thumbKey etc. — the server reads them from the existing blob. Send pictureId instead if you're cloning into a new space.
POST /picture/upload/batch/selected — admin URL ingestion
Server-side fetcher: hand it a list of public URLs and it downloads, dedupes, uploads to R2, and persists each as a public-gallery picture. Only admins can call this. Streams SSE (one event per URL plus a final summary), not the JSON envelope.
Request:
{
"urlList": [
"https://upload.wikimedia.org/wikipedia/commons/.../foo.jpg",
"https://upload.wikimedia.org/wikipedia/commons/.../bar.jpg"
],
"namePrefix": "wikimedia-",
"tags": ["nature", "cc0"]
}Limit: 50 URLs per batch.
Per-URL event:
data: {"index":0,"url":"...","status":"success","message":"...","done":false}Final event:
data: {"done":true,"total":50,"successCount":48}curl example for a Worker / cron:
curl -N -X POST https://<host>/api/picture/upload/batch/selected \
-H "Authorization: Bearer iph_live_..." \
-H "Content-Type: application/json" \
-d '{"urlList":["https://...jpg"],"namePrefix":"cc0-","tags":["cc0"]}'For unattended ingestion this is the simplest path — you don't have to handle R2 presigned PUTs yourself.
Stability guarantees
- Scope strings are forever. Once a scope ships, it's never renamed. If a scope is deprecated, it's removed from the public catalog (so it can no longer be granted) but
ApiScopes.hasScopekeeps recognising it for keys that already hold it. - Token format
iph_live_*is stable. Future environments may introduce additional prefixes (e.g.iph_test_*), butiph_live_*always refers to production-grade keys. - Endpoint paths under
/api/...follow semver. Breaking changes to a shipped endpoint are announced and ride on a new major version in the path. Additive changes (new optional fields, new endpoints, new scopes) land any time. - Error code values are stable.
40100is forever "not logged in";40101is forever "no permission / missing scope".
Roadmap
The schema and filter chain were designed so the following land as pure additions, with no migration:
- Per-key rate limits —
rate_limit_rpmcolumn + Bucket4j filter. - IP allowlist —
ip_allowlist CIDR[]column + filter check. - Audit log —
api_key_audittable + async listener. - Token rotation —
rotated_from_key_idcolumn + rotate endpoint. - Publishable / restricted key types — already reserved via the
key_typecolumn on the existing schema. - OAuth-style third-party apps — separate flow, separate entity.
If you're building against this API and would benefit from any of the above sooner, open an issue with a concrete use case.
