Skip to main content
Version: 2026-09-21 (current)

Storage API reference

Every storage row is addressed by its URL from the launch's storage claim — there are no paths to construct. Call the URL exactly as it arrives in the claim, and treat it as opaque.

All storage requests authenticate with the api.token from a launch (not your API key):

Authorization: Bearer <api.token>

See authenticating for how the token works and who can access what for the permission rules.

Operations​

All four rows — context, location, member, and member_location — support the same three operations on their storage URL (and the file operations on their files URL):

MethodEffect
GETRead the row's JSON object.
PUTReplace the row's contents wholesale.
PATCHApply an RFC 6902 JSON Patch to the row.

The examples below use the row URL captured at launch time, e.g. row_url = launch["storage"]["member_location"].

GET: read a row​

GET <row URL>

Returns 200 with your JSON object, exactly as last written — no envelope. A never-written row returns {}.

# $ROW_URL is storage.member_location from the launch
curl "$ROW_URL" \
-H "Authorization: Bearer $API_TOKEN"

PUT: replace a row​

PUT <row URL>
Content-Type: application/json

The body is your raw JSON object (no wrapper). It replaces the row's contents wholesale. Returns 204 No Content.

# $ROW_URL is storage.context from the launch
curl -X PUT "$ROW_URL" \
-H "Authorization: Bearer $API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"weeks": 14, "modules_open": ["intro", "loops"]}'
warning

PUT overwrites everything in the row. If two parts of your tool store different keys in the same row, use PATCH so one write can't clobber the other's keys.

PATCH: apply a JSON Patch​

PATCH <row URL>
Content-Type: application/json

The body wraps an RFC 6902 JSON Patch in a patch key. Paths are relative to your object's root (/answers/q2, with no extra prefix). Returns 204 No Content.

{
"patch": [
{ "op": "add", "path": "/answers/q2", "value": "C" },
{ "op": "replace", "path": "/attempts", "value": 2 },
{ "op": "remove", "path": "/draft" }
]
}
# $ROW_URL is storage.member_location from the launch
curl -X PATCH "$ROW_URL" \
-H "Authorization: Bearer $API_TOKEN" \
-H "Content-Type: application/json" \
-d '{"patch": [{"op": "add", "path": "/answers/q2", "value": "C"}]}'

The patch engine is tolerant of missing intermediate paths: an add to /answers/q2 works even if answers doesn't exist yet — the intermediate object is created for you. Patches apply atomically under a row lock, so concurrent patches to different keys both land.

Files​

Each row also keeps files — data too large for the JSON object, or not JSON at all: a submitted PDF, a photo, a recording. The launch's files claim carries one URL per row, beside storage, with the same four keys and the same rule: call it as given. Who may read and write a row's files is the same as for its data.

A file has a key your tool chooses: 1 to 128 characters, letters, digits, ., _, and -, starting with a letter or digit. Writing to a key that holds a file replaces it.

MethodURLEffect
GET<files URL>List the row's files: [{ key, size, modified }], modified in milliseconds since the epoch.
GET<files URL>/<key>One file: { key, size, modified, content_type, url, expires }. url downloads it until expires (milliseconds since the epoch, a few minutes away). 404 when no file has the key.
PUT<files URL>/<key>Ask to store a file. The body describes it, and the response is a signed upload form, below.
DELETE<files URL>/<key>Remove the file. 204, also when there was none.

Uploading a file​

The bytes never pass through the Voshi API. A PUT declares the file and answers with a signed form; your tool then posts the file straight to storage.

sizeintegerrequired

The file's size in bytes, at most 25 MB. Storage refuses an upload that does not match.

mimetypestring

The file's type. Guessed from filename (or the key) when empty.

filenamestring

The name a download is saved as. The key when empty.

attachmentbooleandefault: false

Make downloads save the file rather than show it in the browser.

The response is { url, fields }. POST a multipart/form-data request to url carrying every entry of fields first and the file last, in a field named file:

import requests

files_url = launch["files"]["member_location"]
signed = requests.put(
f"{files_url}/essay.pdf",
headers={"Authorization": f"Bearer {api_token}"},
json={"size": len(pdf_bytes), "mimetype": "application/pdf", "filename": "essay.pdf"},
)
signed.raise_for_status()
form = signed.json()
requests.post(form["url"], data=form["fields"], files={"file": pdf_bytes}).raise_for_status()

The PUT checks the row's quota before signing: 100 files and 100 MB per row. A PUT that would exceed either is refused with 422.

Errors​

StatusCause
401Missing or expired bearer token — take a fresh api.token from a new launch.
403The token's user isn't allowed on this row — e.g. a student writing the shared context or location row, one student's token used on another student's member row, or a user who isn't a member of the row's course.
404The row URL doesn't exist, or the row doesn't belong to your tool.
422Malformed body — e.g. a PATCH without the patch wrapper, or an invalid patch operation. Also how a context or location row answers while its level is still unprovisioned.

Error responses use the standard error envelope.