Storage
Voshi gives your tool four JSON storage rows for every launch — scoped to the course, the placement, the student, and the student-on-placement. Many tools can use them as their entire persistence layer: no database, no user table, no schema migrations.
Each row holds one JSON object that is entirely yours. Voshi never reads or interprets it.
The four scopes
Every launch JWT carries a storage claim with the URLs of the four rows relevant to that launch:
"storage": {
"context": "https://api.link.voshi.com/lti13/v1/contexts/IcT91mBxze/apps/I4ppXy/data",
"location": "https://api.link.voshi.com/lti13/v1/contexts/IcT91mBxze/apps/I4ppXy/locations/ext:quiz1/data",
"member": "https://api.link.voshi.com/lti13/v1/contexts/IcT91mBxze/members/Im8mBr42/apps/I4ppXy/data",
"member_location": "https://api.link.voshi.com/lti13/v1/contexts/IcT91mBxze/members/Im8mBr42/apps/I4ppXy/locations/ext:quiz1/data"
}
Each value is the row's full URL — read and write the row by calling that URL directly with GET, PUT, or PATCH. Use it exactly as given and treat it as opaque: don't parse it or construct these URLs yourself.
| Scope | One row per... | Typical use |
|---|---|---|
context | course | Course-wide settings: term dates, module visibility, instructor preferences |
location | placement (location × course) | Per-assignment configuration: due date overrides, question pool selection |
member | user × course | The student's course-wide state: profile choices, cumulative progress |
member_location | user × placement | The student's work on one assignment: answers, attempts, submission state |
The course rows open at provisioning
The member and member_location rows are open from the first launch — nothing to create, nothing to wait for.
The context and location rows are different: they open when you provision that course and that location. Until then every call against them answers 422:
{
"error": "ValidationError",
"message": "App I4ppXy has not been provisioned for Context IcT91mBxze"
}
In practice a launch never reaches your callback URL before its course is provisioned, so a row URL from a launch claim is already open. The gate matters while you're doing the provisioning — and there, the finish call itself takes an optional JSON body that becomes the row's opening contents, so you rarely need a separate write. See Marking a level ready.
Authenticating: the api claim
The Storage API authenticates as the launching user, not as your tool. The launch JWT's api claim gives you the credential:
"api": {
"domain": "api.link.voshi.com",
"token": "8f14e45fceea167a5a36dedd4bea2543"
}
Send the token as a Bearer header on every call:
Authorization: Bearer <api.token>
The token is tied to the launching user's Voshi session (which lasts about a week). Treat it as per-launch: capture it at launch time, use it server-side during the user's visit, and take a fresh one from their next launch. Don't embed it in client-side code — it acts as that user.
Who can access what
Access is enforced per row, based on the groups the token's user holds in that course:
| Row | student | instructor / manager / assistant / mentor |
|---|---|---|
context | read | read + write |
location | read | read + write |
member | read + write, their own only | read + write, for any member of the course |
member_location | read + write, their own only | read + write, for any member of the course |
Two consequences worth designing around:
- Students cannot write the shared rows. A
PUTorPATCHoncontextorlocationwith a student's token returns403. Course-wide state that students need to change has to live somewhere else — their ownmemberrow, or your own database. - Course staff can read and write any student's rows. That is what makes an instructor-facing view in your tool possible (reviewing a student's answers, resetting an attempt), and it means a student's
member_locationrow is not private to that student. Don't store anything there you wouldn't show their instructor.
A student's own rows are reached at a different URL from the one staff use for the same row — which is why the storage claim hands you ready-to-call URLs chosen for the launching user, and why you must call them as given rather than building them yourself.
Your tool's builders get no automatic access. Being a member of the tool in the dashboard does not grant access to any data row. To read rows for debugging, a builder has to be enrolled in the course as an instructor or a student like anyone else.
Requests for rows outside these rules return 403; a row URL that doesn't exist or doesn't belong to your tool returns 404.
Semantics worth knowing
- Reads return your object exactly as you last wrote it. A never-written row returns
{}. - Writes are atomic per row. Concurrent writers serialize — no torn writes. For read-modify-write flows, prefer
PATCHso you only touch the keys you mean to change. - Carrying a course forward is yours to do. When a school starts a new term by copying an old course, the new course provisions from scratch — and the provision request hands you the previous course's
contextandlocationrow URLs so you can read them and write what matters into the new ones. Voshi doesn't copy anything on its own. See Copied courses. - The JSON object has a size limit: 512 KB on the
memberandmember_locationrows, 1 MB oncontextandlocation, measured as compact JSON. A write over the limit is refused with422. This is configuration-and-state storage; anything larger belongs in the row's files. - Every row keeps files too. The launch's
filesclaim carries a URL per row, besidestorage, for data too large for the object or not JSON at all — up to 25 MB per file and 100 files per row. See Files.
Example: saving a student's progress
import requests
def save_progress(launch, question, answer):
requests.patch(
launch["storage"]["member_location"],
headers={"Authorization": f"Bearer {launch['api']['token']}"},
json={"patch": [
{"op": "add", "path": f"/answers/{question}", "value": answer},
]},
).raise_for_status()
Full endpoint details: Storage API reference.