Stop Motion Bible StoriesFrame by frame

Reference · for the tables

The Table API

The capture tables talk to this backend over a small REST API: they create projects, push numbered frames, and upload the rendered film. Everything below is what a table needs to know.

Every example comes in curl, Python (requests), and JavaScript (fetch) — pick a tab and the whole page follows.

Registration

A brand-new table has no key yet, so it bootstraps one here. The device submits a stable identity string it holds onto — a value it persists (e.g. in a cookie) or its MAC address — and gets back a freshly minted api_key to use on every later request. This is the one endpoint that needs no Authorization header.

POST/api/register/

Body: device_id (required) — the device's persistent identity. Optional name and host label the table; otherwise it's auto-named under an Unassigned host an admin can reassign.

Idempotent. Re-registering the same device_id returns that table's existing key, so a device that lost its key but kept its identity can recover it. A deactivated table returns 403. Note that device_id is an identifier, not a secret — treat the returned key as the real credential.

curl -X POST "$BASE/register/" \
  -H "Content-Type: application/json" \
  -d '{"device_id": "A1:B2:C3:D4:E5:F6"}'
# 201 created (200 if the device was already registered)
{
  "api_key": "k7Qv…f2A",
  "table": {
    "id": 12,
    "name": "Table A1:B2:C3:D4",
    "host": "Unassigned",
    "is_active": true,
    "timezone": "UTC"
  }
}

Authentication

Every request carries the table’s API key in the Authorization header. A key is issued per table — either created in the admin or minted by registration — and identifies which table a request comes from. The snippets below set up the base URL and key that the rest of the examples reuse.

# reused by every example below
export KEY="<your-table-key>"
export BASE="https://smbs.artiswrong.com/api"

# every request sends this header:
#   -H "Authorization: Api-Key $KEY"

A missing, unknown, or deactivated key returns 401. A successful call also refreshes the table’s last_seen heartbeat automatically.

Heartbeat

GET/api/heartbeat/

Connectivity & identity check. Returns who the table is; refreshes last_seen.

curl "$BASE/heartbeat/" \
  -H "Authorization: Api-Key $KEY"
# 200 response
{
  "table": "Table 1",
  "host": "Sunday School Room A",
  "is_active": true,
  "last_seen": "2026-06-24T18:00:00Z",
  "timezone": "America/New_York"
}

Time zone

Timestamps are stored and returned in UTC. A table reports the local IANA time zone it operates in so its times can be presented correctly.

GET/api/timezone/

Read the calling table's current time zone.

curl "$BASE/timezone/" \
  -H "Authorization: Api-Key $KEY"
POST/api/timezone/

Set the calling table's time zone. The value must be a valid IANA name (e.g. America/New_York); an unknown name returns 400.

FieldTypeNotes
timezonestringrequired · IANA time zone
curl -X POST "$BASE/timezone/" \
  -H "Authorization: Api-Key $KEY" \
  -H "Content-Type: application/json" \
  -d '{"timezone": "America/New_York"}'
# 200 response
{ "table": "Table 1", "timezone": "America/New_York" }

Projects

GET/api/projects/

List the projects this table has worked on.

curl "$BASE/projects/" \
  -H "Authorization: Api-Key $KEY"
POST/api/projects/

Create a project. If the user_id is new, the user is created automatically. The calling table is recorded on the project.

FieldTypeNotes
user_idstringrequired · the user’s numeric id
titlestringrequired
is_publicbooleanoptional · default false
curl "$BASE/projects/" \
  -H "Authorization: Api-Key $KEY" \
  -d "user_id=100042" -d "title=The Parting of the Sea"
# 201 response
{ "id": 7, "user": "100042", "title": "The Parting of the Sea",
  "is_public": false, "frame_count": 0, "frames": [], "video": null,
  "tables": ["Table 1"], "created_at": "…", "updated_at": "…" }
GET/api/projects/<id>/

Retrieve a project with its frames (S3 image URLs) and video.

curl "$BASE/projects/7/" \
  -H "Authorization: Api-Key $KEY"
GET/api/projects/<id>/manifest/

A compact JSON manifest: the project's title, a thumbnail, the video, and every frame's number + url in order. This is the index for repopulating a project: a table that offloaded a project to free space fetches the manifest when someone returns to it, then pulls each frame — one by one from its download URL, or all at once as a ZIP.

curl "$BASE/projects/7/manifest/" \
  -H "Authorization: Api-Key $KEY"
# 200 response
{
  "id": 7,
  "title": "Jonah & the Whale",
  "user_id": "100042",
  "is_public": false,
  "frame_count": 2,
  "thumbnail": "https://s3.us-east-1.amazonaws.com/…/001.png",
  "frames": [
    { "number": 1, "url": "https://s3.us-east-1.amazonaws.com/…/001.png" },
    { "number": 2, "url": "https://s3.us-east-1.amazonaws.com/…/002.png" }
  ],
  "video": { "url": "https://s3.us-east-1.amazonaws.com/…/out.mp4", "duration_seconds": 12.5 },
  "updated_at": "2026-06-25T18:00:00Z"
}
PATCH/api/projects/<id>/

Update title and/or is_public. Publishing a project (is_public: true) makes it appear in the public gallery.

curl -X PATCH "$BASE/projects/7/" \
  -H "Authorization: Api-Key $KEY" \
  -H "Content-Type: application/json" \
  -d '{"is_public": true}'

Frames

POST/api/projects/<id>/frames/

Upsert a numbered frame. Posting a number that already exists replaces its image (and deletes the old file). Returns 201 for a new frame, 200 for a replacement.

FieldTypeNotes
numberintegerrequired · the frame’s position
imagefilerequired · multipart upload
curl "$BASE/projects/7/frames/" \
  -H "Authorization: Api-Key $KEY" \
  -F "number=1" -F "image=@frame_001.png"
GET/api/projects/<id>/frames/download/

Download all of a project's frames as a single ZIP, each named by its number (001.png, 002.png, …) so they restore in order. The one-shot way to repopulate an offloaded project. 404 if the project has no frames.

curl "$BASE/projects/7/frames/download/" \
  -H "Authorization: Api-Key $KEY" -o frames.zip
GET/api/projects/<id>/frames/<number>/download/

Download a single frame's image as a file attachment. Use the manifest to enumerate frame numbers, then pull them one at a time.

curl "$BASE/projects/7/frames/1/download/" \
  -H "Authorization: Api-Key $KEY" -OJ
DELETE/api/projects/<id>/frames/<number>/

Delete a single frame by its number (and its stored image). Returns 204.

curl -X DELETE "$BASE/projects/7/frames/1/" \
  -H "Authorization: Api-Key $KEY"

Video

POST/api/projects/<id>/video/

Upload or replace the rendered film for a project. Re-uploading swaps the file and removes the old one.

FieldTypeNotes
filefilerequired · multipart upload
duration_secondsnumberoptional
curl "$BASE/projects/7/video/" \
  -H "Authorization: Api-Key $KEY" \
  -F "file=@story.mp4" -F "duration_seconds=18.5"

The response carries needs_sync and server_rendered flags. A fresh upload always clears both — an uploaded film overrides any server-side render.

POST/api/projects/<id>/video/render/

Ask the server to build a video from the project's frames with FFmpeg — useful when a project has frames but no uploaded film. The result is stored as the project's video and flagged server_rendered. Rendering defaults to 9 fps; pass fps to override.

Returns 201 when a video is created, 200 when a previous server render is replaced, and 409 if a table already uploaded a real video (the server won't overwrite it). 400 if the project has no frames. A later table upload always overrides the render.

FieldTypeNotes
fpsnumberoptional · default 9
curl -X POST "$BASE/projects/7/video/render/" \
  -H "Authorization: Api-Key $KEY" \
  -H "Content-Type: application/json" \
  -d '{"fps": 9}'
POST/api/projects/<id>/video/mark-changed/

Mark a project's existing video as changed when the table's local render has updated but the new file hasn't been uploaded yet. Sets needs_sync on the video; re-uploading the file clears it. 404 if the project has no video yet.

curl -X POST "$BASE/projects/7/video/mark-changed/" \
  -H "Authorization: Api-Key $KEY"

Status codes

200OK — including a replaced frame/video
201Created — new project or new frame
204Deleted — no content returned
400Bad request — missing/invalid fields
401Bad, missing, or deactivated API key
404No such project, frame, or video