Box HTTP API
Last updated 2026-09-21
Everything below is served by the box itself, at the box's own origin
(https://pb-1234.plumbox.me, or a LAN address). There is no Plum server in the path.
This page covers what a third-party app needs. The machine-readable spec is at
GET /openapi.json (no auth) — but note it currently documents only auth, drive, photos,
shares, quota and storage; /api/apps/*, /api/oauth/*, /api/events and /apps/{id}/svc/* are
described here and not yet in that document.
Authentication
| Credential | How | Where it works |
|---|---|---|
| Session cookie | session=…, set by the box's own login |
the web shell, panels |
| Personal access token | Authorization: Bearer plum_pat_… |
/api/* and /apps/{id}/svc/* |
| Basic | Authorization: Basic base64(<username>:plum_pat_…) |
/apps/{id}/svc/* (the username must own the token) |
An OAuth exchange hands you a PAT, so "access token" and "personal access token" are the same thing on a box.
Scopes: files:read, files:write, user:profile, service:call:<app_id>, plus
apps:install and apps:dev for developer tooling, and the legacy read / write / admin.
Enforcement is per request: read covers GET/HEAD/OPTIONS and write covers the rest;
files:write implies files:read; admin covers everything but is not obtainable through
OAuth. A user:profile token opens exactly GET /api/auth/me; a service:call:<id> token opens
that app's /svc/* and GET /api/apps/<id>/entitlement and nothing else; apps:install and
apps:dev tokens are confined to /api/apps/.
Tokens
| Method | Path | Notes |
|---|---|---|
POST |
/api/auth/tokens |
session only. {"name","scopes":[…],"expiresInDays":null} → the token once, as token |
GET |
/api/auth/tokens |
list (prefixes only) |
DELETE |
/api/auth/tokens/{id} |
revoke |
GET |
/api/auth/me |
the signed-in user |
OAuth
| Method | Path | Auth | Purpose |
|---|---|---|---|
GET |
/authorize |
session | the consent screen (the portal redirects the user here) |
POST |
/api/oauth/authorize |
session | approve; returns {"code","redirect_uri","state"} |
POST |
/api/oauth/token |
none | exchange the code for a token — PKCE is the proof |
GET |
/api/oauth/clients/{client_id} |
session | look up a registered client |
code_challenge_method=S256 is required, codes live 5 minutes and are single-use, the only
grant_type is authorization_code, and there are no refresh tokens. Details and the client
rules are in Companion apps.
Apps
| Method | Path | What |
|---|---|---|
GET |
/api/apps |
installed apps (id, name, version, permissions, launch URL, update_available) |
GET |
/api/apps/overview |
the same with sizes, author, service state, publisherKid, countersignKind |
GET |
/api/apps/{id}/status |
service state: starting/ready/crashed/give_up/start_timeout/stopped, restarts, limits, memory_current, oom_kills |
POST |
/api/apps/{id}/restart |
restart the service |
GET |
/api/apps/{id}/logs[?follow=1] |
log snapshot, or SSE; a token needs apps:dev |
GET |
/api/apps/{id}/entitlement |
{"skus":[{sku,kind,expires_at,active}],"refreshed_at","stale"} |
POST |
/api/apps/entitlements/refresh |
refetch receipts now → {"ok":true,"receipts":n} |
GET |
/api/apps/{id}/permissions · POST |
what the user has enabled, and toggling it |
POST |
/api/apps/install[?app_id=…] |
install a signed .plu (multipart plu, or a raw zip body); needs apps:install |
POST |
/api/apps/store/install |
install from the store by {"store_app_id","version"} |
GET |
/api/apps/store/listing[?q=] |
the store catalog, annotated with what is installed |
GET |
/api/apps/store/compatible |
the "works with Plum Box" shelf (companion-only apps) |
POST |
/api/apps/{id}/update · PATCH /api/apps/{id}/settings |
update now; {"auto_update":bool} |
DELETE |
/api/apps/{id} · POST /api/apps/{id}/uninstall |
remove |
GET |
/api/apps/trust · PUT |
the box's trust level (owner) |
GET |
/api/apps/publishers |
trusted publisher keys and the embedded store key ids |
POST |
/api/apps/publishers/pair · /pair/confirm |
developer pairing (no auth: the 6-digit code the owner reads off the box is the proof) |
There is no per-app client endpoint — GET /api/oauth/clients/{client_id} is the only client
lookup.
Events
GET /api/events?kinds=drive,photos,apps:dev.jan.notes
Accept: text/event-stream
Server-sent events. kinds is a comma-separated filter (empty = everything) and accepts
<prefix>:* wildcards such as apps:*. Reconnect with the Last-Event-ID header (or
?last_id=); if too much was missed the box sends event: reset and you should re-read rather
than patch. Frames are id: <n> / event: <kind> / data: {"id","kind","at","payload"}, with a
: ping comment every 25 seconds and retry: 3000. At most 256 concurrent listeners per box.
Kinds published today: drive and photos (after any successful write under /api/drive/ or
/api/photos/, payload {"path","method"}), notification, and apps:<app_id> — both from a
service's control socket and from the box itself on install ({"type":"installed"}).
An app's own service
ANY /apps/{app_id}/svc/{path}
Proxied to the app's service with the caller's identity in X-Plum-* headers. A PAT needs the
exact service:call:<app_id> scope. See Service apps for the headers and
the failure codes.
Error shapes
Three, depending on the layer:
{"error": "forbidden", "message": "token lacks the apps:dev scope"} // /api/apps/*
{"error": "invalid_scope", "error_description": "…"} // OAuth
{"error": "app backend unavailable"} // the svc proxy
Developer-facing tools should read error and show message / error_description.