A small bearer-authenticated HTTP API for photo-frame devices: fetch a manifest of every photo, fetch a single photo's bytes by id, or upload new photos. Each token is bound to a single frame.
Every request requires an Authorization: Bearer <token> header.
Tokens are issued by the frame owner from the frame's access page and start
with the prefix sk_. The server stores only a SHA-256 digest, so a
token is shown to the owner exactly once at creation time and cannot be
recovered after that.
A token grants read + upload access to one specific frame. Revoking it from the access page immediately disables it. If a token is leaked, revoke and reissue.
Failed authentication returns:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="photo-frame"
Content-Type: application/json
{"error":"invalid_token"}
A frame that doesn't have a token yet bootstraps itself through the Device enrollment endpoints below — the only unauthenticated surface of the API. The bearer token is the output of that flow.
Three unauthenticated endpoints that let an IoT photo frame acquire a bearer token without ever typing a credential. The frame mints a short-lived pairing code, renders it as an on-screen QR, and long-polls for the result. A signed-in member of some frame scans the QR with their phone, picks one of their accessible frames, and confirms — the server then publishes the minted token over the long-poll so the device can stop showing the QR and start fetching the manifest.
The pairing's plaintext code is the secret: anyone who holds it can claim the pairing and watch the long-poll. The server stores only a SHA-256 digest. Pairings have a hard 10-minute TTL and are single-use — if the user doesn't claim in time, the device mints a new one.
The long-poll endpoint reads the plaintext code from an
X-Pairing-Code request header rather than the URL path.
That keeps the secret out of access logs and any path-based caches
in front of the app, and lets the events stream be pinned to a
static URL on the SSE-serving role behind the load balancer.
POST /api/v1/device_pairingsMints a new device pairing. Unauthenticated. Returns the plaintext code (only ever shown here), the absolute claim URL to encode in the QR, the URL for the long-poll SSE stream, and the pairing's absolute expiry. All URLs are absolute so a freshly-flashed device with no base-URL config can follow them directly.
application/json; body is { code, claim_url, events_url, expires_at }. The code is URL-safe base64; treat it as opaque and never log it.Example:
curl -X POST https://example.com/api/v1/device_pairings
HTTP/1.1 200 OK
Content-Type: application/json
{
"code": "Yk9...d3Q",
"claim_url": "https://example.com/device/pair/Yk9...d3Q",
"events_url": "https://example.com/api/v1/device_pairings/events",
"expires_at": "2026-05-13T16:04:54Z"
}
GET /api/v1/device_pairings/events
Long-lived Server-Sent Events
stream that blocks until the phone-side claim completes or the
pairing expires. The pairing is identified by the
X-Pairing-Code request header. One-shot: a single
terminal event is written, then the server closes the connection. A
heartbeat comment line :\n\n is sent every 30 seconds
to keep NAT/proxy idle timeouts from dropping the connection while
the user fumbles for their phone.
Two terminal events are possible:
event: claimed — data: is a JSON object { "token": "sk_...", "frame": { "id": …, "title": …, "width": …, "height": … } }. The device stores the token, sizes its display to the frame's dimensions, and starts polling GET /api/v1/photos/manifest.event: expired — data: {}. The pairing's 10-minute TTL elapsed without a claim. The device should mint a fresh pairing via POST /api/v1/device_pairings and re-render its QR.Content-Type: text/event-stream. The stream opens with an immediate heartbeat comment so intermediaries start flushing, then blocks until a terminal event fires.Example:
curl -N https://example.com/api/v1/device_pairings/events \
-H "X-Pairing-Code: Yk9...d3Q" \
-H "Accept: text/event-stream"
HTTP/1.1 200 OK
Content-Type: text/event-stream
:
event: claimed
data: {"token":"sk_xxxxxxxxxxxxxxxxxxxx","frame":{"id":42,"title":"Living room","width":1920,"height":1080}}
GET /api/v1/photos/manifest
Returns a JSON array of photo objects for every kept, processed photo
on the frame, in display order. Each object has an id and
an absolute url that points at
GET /api/v1/photos/:id and requires the same bearer token
to fetch. Photos still being processed are omitted; the array reflects
the current order at request time, so clients should re-fetch when the
order changes.
application/json; body is an array of { id, url } objects. Response carries a weak ETag header of the form W/"<digest>". The <digest> inside the quotes is an opaque fingerprint of the manifest's contents and order — the same string the SSE refresh_manifest event carries in its data payload. Remember it after each fetch: when a later refresh_manifest event reports the same digest, the manifest is unchanged and no refetch is needed.If-None-Match matched the current ETag (send the previous response's ETag back verbatim, wrapper and all); body is empty. Use this to poll without paying for the JSON body.
Response uses Rails' default
Cache-Control: max-age=0, private, must-revalidate —
the manifest is per-frame and can change as photos are added, removed,
or reordered, so shared caches must not store it and clients must
revalidate before re-using a copy.
Example:
curl -i https://example.com/api/v1/photos/manifest \
-H "Authorization: Bearer sk_xxxxxxxxxxxxxxxxxxxx"
HTTP/1.1 200 OK
Content-Type: application/json
ETag: W/"3b1f...c4"
[
{ "id": 41, "url": "https://example.com/api/v1/photos/41" },
{ "id": 42, "url": "https://example.com/api/v1/photos/42" },
{ "id": 43, "url": "https://example.com/api/v1/photos/43" }
]
GET /api/v1/photos/events
Long-lived Server-Sent Events
stream. The connection stays open; the server writes one event whenever
the manifest changes (photo added, removed, or reordered) so
the device can re-fetch GET /api/v1/photos/manifest only
when there's something new.
Each push is two lines: an event: naming the event type
and a data: carrying a JSON payload. The only event type
today is refresh_manifest; its data is
{ "digest": "<digest>" }, where
<digest> is an opaque fingerprint of the current
manifest — the same string the manifest endpoint returns in its
ETag. Compare it to the digest of the manifest you last
fetched: if it differs, refetch
GET /api/v1/photos/manifest; if it matches, the push is
redundant and can be ignored. Further event types may be added to this
stream in the future, so clients should dispatch on the
event: name — native EventSource
clients via addEventListener('refresh_manifest', …)
— and treat any unrecognized type as a no-op. A comment line
:\n\n is sent every 90 seconds as a heartbeat to keep the
connection alive through NAT and proxy idle timeouts.
Content-Type: text/event-stream. Every connection — first time or reconnect — opens with a refresh_manifest event carrying the current digest, so a client always learns the manifest's current state on (re)connect. Deciding whether that warrants a refetch is the client's job: compare the digest to the one it last fetched.
The digest is opaque; treat it as a string to compare for equality and
do not parse it. On disconnect, reconnect with the same token and a
fresh GET. Events are not replayed, and the stream carries
no id: field — there is nothing to resume. Opening
with refresh_manifest plus client-side digest comparison
means a reconnect on an unchanged manifest costs one small event and no
manifest refetch.
Example:
curl -N https://example.com/api/v1/photos/events \
-H "Authorization: Bearer sk_xxxxxxxxxxxxxxxxxxxx" \
-H "Accept: text/event-stream"
HTTP/1.1 200 OK
Content-Type: text/event-stream
event: refresh_manifest
data: {"digest":"3b1f...c4"}
:
event: refresh_manifest
data: {"digest":"9a2e...07"}
GET /api/v1/photos/:idReturns the resized image bytes for one photo by id, as listed in the manifest. The bearer token must belong to the same frame as the photo; ids that don't exist, aren't yet processed, or belong to another frame return 404.
Content-Type is the image's stored type (typically image/jpeg).
A 200 response also carries X-Photo-Index and
X-Photo-Total: the photo's 0-based position
in the manifest and the total number of photos in it. A client steps to
the next photo by fetching manifest[(index + 1) % total].
These headers reflect the manifest at the moment of a fresh fetch —
because the image body is cached immutable (below), a
response replayed from cache may carry a stale index, so treat
GET /api/v1/photos/manifest as the source of truth for
navigation.
Response sets
Cache-Control: max-age=31536000, private, immutable:
processed image bytes never change, so the device can cache them
indefinitely without revalidation.
A bearer token can also add photos to its frame. Uploads are two-phase: the original image bytes travel straight from the client to object storage through a short-lived presigned URL and never pass through the API server, so a large upload costs the app no bandwidth.
POST /api/v1/photos/direct_upload — declare the
file's name, size, type, and checksum. The server validates the
request and returns a signed_id plus a presigned
PUT URL.
PUT the raw image bytes to that URL with the headers the
previous step returned. This request goes to object storage, not to
the API.
POST /api/v1/photos — hand the
signed_id back to attach the uploaded file to the frame.
The confirm step creates the photo in a pending state and
enqueues a background job that resizes the original to the frame's
exact pixel dimensions — the whole photo is kept and letterboxed
with black bars rather than cropped — strips its metadata, then
permanently purges the original. When that finishes the photo flips to
processed and joins the manifest; a client watching
GET /api/v1/photos/events receives a
refresh_manifest event at that point. There is no
upload-status endpoint — watch the events stream or re-poll the
manifest.
Both device- and viewer-kind tokens may
upload. Accepted types are JPEG, PNG, WebP, and HEIC/HEIF, up to 30 MB
per file. Each frame has a maximum photo count and a rolling 24-hour
upload-byte quota (2 GB); an upload that would breach either is
rejected.
POST /api/v1/photos/direct_upload
First step of an upload. The JSON request body has a blob
object describing the file you are about to upload:
filename, byte_size (in bytes),
content_type, and checksum (the
base64-encoded MD5 digest of the file). The server checks size, type,
frame capacity, and quota before minting anything, then
creates a pending blob and returns it.
application/json; body is { signed_id, direct_upload: { url, headers } }. PUT the file bytes to direct_upload.url, sending every name/value pair in direct_upload.headers as a request header. Keep the signed_id for the confirm step.application/json { error }, where error is one of invalid_request (malformed body), invalid_upload (over 30 MB or a disallowed type), frame_full (the frame is at its photo-count cap), or quota_exceeded (the 24-hour upload-byte quota would be exceeded).
Any metadata sent inside the blob object is
ignored — blob metadata is server-controlled.
Example — mint, then upload the bytes:
curl -X POST https://example.com/api/v1/photos/direct_upload \
-H "Authorization: Bearer sk_xxxxxxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{"blob":{"filename":"sunset.jpg","byte_size":482113,"content_type":"image/jpeg","checksum":"rL0Y20zC+Fzt72VPzMSk2A=="}}'
HTTP/1.1 200 OK
Content-Type: application/json
{
"signed_id": "eyJfcmFpbHMiOnsi...",
"direct_upload": {
"url": "https://objectstorage.example.com/bucket/abc123?...",
"headers": { "Content-Type": "image/jpeg", "Content-MD5": "rL0Y20zC+Fzt72VPzMSk2A==" }
}
}
curl -X PUT "https://objectstorage.example.com/bucket/abc123?..." \
-H "Content-Type: image/jpeg" \
-H "Content-MD5: rL0Y20zC+Fzt72VPzMSk2A==" \
--data-binary @sunset.jpg
POST /api/v1/photos
Final step of an upload. The JSON request body carries a single
signed_id — the value returned by
direct_upload — for a file you have already
PUT to object storage. The server attaches the blob to the
frame, creates the photo in pending state, and enqueues the
background resize. One photo per request.
application/json; body is { id, processing_status }. processing_status is pending; the photo appears in the manifest once the background resize finishes and it becomes processed.application/json { error }, where error is one of invalid_request (no signed_id), invalid_signed_id (unknown or expired), invalid_upload (the uploaded file is over-size or a disallowed type), frame_full, quota_exceeded, or already_attached (that signed_id has already been used).The photo is recorded as uploaded by the frame's owner. Every viewer of the frame sees the resized image; the original is purged.
Example:
curl -X POST https://example.com/api/v1/photos \
-H "Authorization: Bearer sk_xxxxxxxxxxxxxxxxxxxx" \
-H "Content-Type: application/json" \
-d '{"signed_id":"eyJfcmFpbHMiOnsi..."}'
HTTP/1.1 201 Created
Content-Type: application/json
{ "id": 44, "processing_status": "pending" }
Authenticated requests are bucketed per token; requests carrying no token fall back to a per-IP bucket with the same caps. Several per-token buckets apply:
/photos/manifest,
/photos/events, and both upload endpoints.
GET /photos/:id so a device caching an entire frame on
first sync doesn't starve its own manifest polls.
POST /photos/direct_upload carries its own cap of
300 / hour and POST /photos its own cap
of 200 / hour.
A breach of either bucket returns 429 Too Many Requests
with a Retry-After header (seconds).
The device-enrollment endpoints have their own per-IP caps so a bursty enrollment can't trip the per-token buckets:
POST /api/v1/device_pairings — 20 / hour per IP.GET /api/v1/device_pairings/events — 30 / minute per IP. Generous so a device on a flaky network can reconnect repeatedly; the 10-minute pairing TTL bounds the window.
The path is versioned (/api/v1/...). Breaking changes ship under a
new version; v1 stays stable.