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.
/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"}'
import requests
BASE = "https://smbs.artiswrong.com/api"
r = requests.post(f"{BASE}/register/", json={"device_id": "A1:B2:C3:D4:E5:F6"})
key = r.json()["api_key"] # persist this for all later calls
const BASE = "https://smbs.artiswrong.com/api";
const r = await fetch(`${BASE}/register/`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ device_id: "A1:B2:C3:D4:E5:F6" }),
});
const { api_key } = await r.json(); // persist this
# 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"
# reused by every example below import requests BASE = "https://smbs.artiswrong.com/api" session = requests.Session() session.headers["Authorization"] = "Api-Key <your-table-key>"
// reused by every example below const BASE = "https://smbs.artiswrong.com/api"; const headers = { "Authorization": "Api-Key <your-table-key>" };
A missing, unknown, or deactivated key returns
401. A successful call also refreshes the table’s
last_seen heartbeat automatically.
Heartbeat
/api/heartbeat/Connectivity & identity check. Returns who the table is; refreshes
last_seen.
curl "$BASE/heartbeat/" \ -H "Authorization: Api-Key $KEY"
r = session.get(f"{BASE}/heartbeat/")
print(r.json())
const r = await fetch(`${BASE}/heartbeat/`, { headers });
const data = await r.json();
# 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.
/api/timezone/Read the calling table's current time zone.
curl "$BASE/timezone/" \ -H "Authorization: Api-Key $KEY"
print(session.get(f"{BASE}/timezone/").json())
const tz = await (await fetch(`${BASE}/timezone/`, { headers })).json();
/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.
| Field | Type | Notes |
|---|---|---|
timezone | string | required · IANA time zone |
curl -X POST "$BASE/timezone/" \
-H "Authorization: Api-Key $KEY" \
-H "Content-Type: application/json" \
-d '{"timezone": "America/New_York"}'
r = session.post(f"{BASE}/timezone/", json={"timezone": "America/New_York"})
print(r.json())
const r = await fetch(`${BASE}/timezone/`, {
method: "POST",
headers: { ...headers, "Content-Type": "application/json" },
body: JSON.stringify({ timezone: "America/New_York" }),
});
# 200 response
{ "table": "Table 1", "timezone": "America/New_York" }
Projects
/api/projects/List the projects this table has worked on.
curl "$BASE/projects/" \ -H "Authorization: Api-Key $KEY"
projects = session.get(f"{BASE}/projects/").json()
const projects = await (await fetch(`${BASE}/projects/`, { headers })).json();
/api/projects/Create a project. If the user_id is new, the user is
created automatically. The calling table is recorded on the project.
| Field | Type | Notes |
|---|---|---|
user_id | string | required · the user’s numeric id |
title | string | required |
is_public | boolean | optional · default false |
curl "$BASE/projects/" \ -H "Authorization: Api-Key $KEY" \ -d "user_id=100042" -d "title=The Parting of the Sea"
r = session.post(f"{BASE}/projects/", json={
"user_id": "100042",
"title": "The Parting of the Sea",
})
const r = await fetch(`${BASE}/projects/`, {
method: "POST",
headers: { ...headers, "Content-Type": "application/json" },
body: JSON.stringify({
user_id: "100042",
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": "…" }
/api/projects/<id>/Retrieve a project with its frames (S3 image URLs) and video.
curl "$BASE/projects/7/" \ -H "Authorization: Api-Key $KEY"
project = session.get(f"{BASE}/projects/7/").json()
const project = await (await fetch(`${BASE}/projects/7/`, { headers })).json();
/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"
m = session.get(f"{BASE}/projects/7/manifest/").json()
for f in m["frames"]:
img = session.get(f["url"]).content # repopulate frame f["number"]
const m = await (await fetch(`${BASE}/projects/7/manifest/`, { headers })).json();
for (const f of m.frames) { /* download f.url -> frame f.number */ }
# 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"
}
/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}'
session.patch(f"{BASE}/projects/7/", json={"is_public": True})
await fetch(`${BASE}/projects/7/`, {
method: "PATCH",
headers: { ...headers, "Content-Type": "application/json" },
body: JSON.stringify({ is_public: true }),
});
Frames
/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.
| Field | Type | Notes |
|---|---|---|
number | integer | required · the frame’s position |
image | file | required · multipart upload |
curl "$BASE/projects/7/frames/" \ -H "Authorization: Api-Key $KEY" \ -F "number=1" -F "image=@frame_001.png"
with open("frame_001.png", "rb") as image:
r = session.post(
f"{BASE}/projects/7/frames/",
data={"number": 1},
files={"image": image},
)
const form = new FormData();
form.append("number", "1");
form.append("image", fileInput.files[0]); // a File from an <input type="file">
const r = await fetch(`${BASE}/projects/7/frames/`, {
method: "POST",
headers, // no Content-Type — the browser sets the multipart boundary
body: form,
});
/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
r = session.get(f"{BASE}/projects/7/frames/download/")
open("frames.zip", "wb").write(r.content)
const blob = await (await fetch(`${BASE}/projects/7/frames/download/`, { headers })).blob();
/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
r = session.get(f"{BASE}/projects/7/frames/1/download/")
open("001.png", "wb").write(r.content)
const blob = await (await fetch(`${BASE}/projects/7/frames/1/download/`, { headers })).blob();
/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"
session.delete(f"{BASE}/projects/7/frames/1/")
await fetch(`${BASE}/projects/7/frames/1/`, { method: "DELETE", headers });
Video
/api/projects/<id>/video/Upload or replace the rendered film for a project. Re-uploading swaps the file and removes the old one.
| Field | Type | Notes |
|---|---|---|
file | file | required · multipart upload |
duration_seconds | number | optional |
curl "$BASE/projects/7/video/" \ -H "Authorization: Api-Key $KEY" \ -F "file=@story.mp4" -F "duration_seconds=18.5"
with open("story.mp4", "rb") as video:
r = session.post(
f"{BASE}/projects/7/video/",
data={"duration_seconds": 18.5},
files={"file": video},
)
const form = new FormData();
form.append("file", videoInput.files[0]);
form.append("duration_seconds", "18.5");
const r = await fetch(`${BASE}/projects/7/video/`, {
method: "POST",
headers,
body: form,
});
The response carries needs_sync and
server_rendered flags. A fresh upload always clears both —
an uploaded film overrides any server-side
render.
/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.
| Field | Type | Notes |
|---|---|---|
fps | number | optional · default 9 |
curl -X POST "$BASE/projects/7/video/render/" \
-H "Authorization: Api-Key $KEY" \
-H "Content-Type: application/json" \
-d '{"fps": 9}'
r = session.post(f"{BASE}/projects/7/video/render/", json={"fps": 9})
const r = await fetch(`${BASE}/projects/7/video/render/`, {
method: "POST",
headers: { ...headers, "Content-Type": "application/json" },
body: JSON.stringify({ fps: 9 }),
});
/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"
session.post(f"{BASE}/projects/7/video/mark-changed/")
await fetch(`${BASE}/projects/7/video/mark-changed/`, { method: "POST", headers });
Status codes
| 200 | OK — including a replaced frame/video |
| 201 | Created — new project or new frame |
| 204 | Deleted — no content returned |
| 400 | Bad request — missing/invalid fields |
| 401 | Bad, missing, or deactivated API key |
| 404 | No such project, frame, or video |