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):
| Method | Effect |
|---|---|
GET | Read the row's JSON object. |
PUT | Replace the row's contents wholesale. |
PATCH | Apply 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 {}.
- curl
- Python
- Node
# $ROW_URL is storage.member_location from the launch
curl "$ROW_URL" \
-H "Authorization: Bearer $API_TOKEN"
import requests
resp = requests.get(
launch["storage"]["member_location"],
headers={"Authorization": f"Bearer {api_token}"},
)
resp.raise_for_status()
data = resp.json() # e.g. {"answers": {"q1": "B"}, "attempts": 1}
const resp = await fetch(
launch.storage.member_location,
{ headers: { Authorization: `Bearer ${apiToken}` } }
)
const data = await resp.json() // e.g. {"answers": {"q1": "B"}, "attempts": 1}
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.
- curl
- Python
- Node
# $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"]}'
requests.put(
launch["storage"]["context"],
headers={"Authorization": f"Bearer {api_token}"},
json={"weeks": 14, "modules_open": ["intro", "loops"]},
).raise_for_status()
await fetch(
launch.storage.context,
{
method: 'PUT',
headers: {
Authorization: `Bearer ${apiToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ weeks: 14, modules_open: ['intro', 'loops'] }),
}
)
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" }
]
}
- curl
- Python
- Node
# $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"}]}'
requests.patch(
launch["storage"]["member_location"],
headers={"Authorization": f"Bearer {api_token}"},
json={"patch": [{"op": "add", "path": "/answers/q2", "value": "C"}]},
).raise_for_status()
await fetch(
launch.storage.member_location,
{
method: 'PATCH',
headers: {
Authorization: `Bearer ${apiToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
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.
| Method | URL | Effect |
|---|---|---|
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.
sizeintegerrequiredThe file's size in bytes, at most 25 MB. Storage refuses an upload that does not match.
mimetypestringThe file's type. Guessed from filename (or the key) when empty.
filenamestringThe name a download is saved as. The key when empty.
attachmentbooleandefault: falseMake 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:
- Python
- Node
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()
const filesUrl = launch.files.member_location
const signed = await fetch(`${filesUrl}/essay.pdf`, {
method: 'PUT',
headers: { Authorization: `Bearer ${apiToken}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ size: pdf.size, mimetype: 'application/pdf', filename: 'essay.pdf' }),
})
const form = await signed.json()
const body = new FormData()
for (const [name, value] of Object.entries(form.fields)) body.append(name, value)
body.append('file', pdf) // the file last
await fetch(form.url, { method: 'POST', body })
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
| Status | Cause |
|---|---|
401 | Missing or expired bearer token — take a fresh api.token from a new launch. |
403 | The 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. |
404 | The row URL doesn't exist, or the row doesn't belong to your tool. |
422 | Malformed 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.