Photo Frame API

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.

Authentication

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.

Device enrollment

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_pairings

Mints 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.

  • 200 OKapplication/json; body is { code, claim_url, events_url, expires_at }. The code is URL-safe base64; treat it as opaque and never log it.
  • 429 Too Many Requests — per-IP rate limit exceeded.

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: claimeddata: 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: expireddata: {}. 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.
  • 200 OKContent-Type: text/event-stream. The stream opens with an immediate heartbeat comment so intermediaries start flushing, then blocks until a terminal event fires.
  • 404 Not Found — missing/unknown code or not a device-kind pairing.
  • 410 Gone — the pairing has already been claimed or has already expired. Mint a new pairing.
  • 429 Too Many Requests — per-IP rate limit exceeded; opening a stream consumes one slot.

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.

  • 200 OKapplication/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.
  • 304 Not Modified — the request's 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.
  • 401 Unauthorized — missing or revoked token.
  • 429 Too Many Requests — rate limit exceeded.

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.

  • 200 OKContent-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.
  • 401 Unauthorized — missing or revoked token.
  • 429 Too Many Requests — rate limit exceeded; opening a stream consumes one slot from the general API bucket.

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/:id

Returns 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.

  • 200 OK — body is the image; Content-Type is the image's stored type (typically image/jpeg).
  • 404 Not Found — unknown id, not yet processed, or not on this token's frame.
  • 401 Unauthorized — missing or revoked token.
  • 429 Too Many Requests — rate limit exceeded.

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.

Uploading photos

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.

  1. 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.
  2. 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.
  3. 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.

  • 200 OKapplication/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.
  • 401 Unauthorized — missing or revoked token.
  • 422 Unprocessable Entityapplication/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).
  • 429 Too Many Requests — rate limit 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.

  • 201 Createdapplication/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.
  • 401 Unauthorized — missing or revoked token.
  • 422 Unprocessable Entityapplication/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).
  • 429 Too Many Requests — rate limit exceeded.

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" }

Rate limits

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:

  • General API30 / minute and 600 / hour. Covers /photos/manifest, /photos/events, and both upload endpoints.
  • Image downloads200 / minute and 2000 / hour, a separate, more generous bucket for GET /photos/:id so a device caching an entire frame on first sync doesn't starve its own manifest polls.
  • Photo uploads — on top of the general bucket, 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_pairings20 / hour per IP.
  • GET /api/v1/device_pairings/events30 / minute per IP. Generous so a device on a flaky network can reconnect repeatedly; the 10-minute pairing TTL bounds the window.

Versioning

The path is versioned (/api/v1/...). Breaking changes ship under a new version; v1 stays stable.