App data storage
Voshi gives your app four JSON storage rows for every launch — scoped to the course, the placement, the student, and the student-on-placement. Many apps 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 App Data API authenticates as the launching user, not as your app. 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 app 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 app's builders get no automatic access. Being a member of the app 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 app 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. - No fixed size limit is enforced on the JSON, but the practical request-size ceiling is a few megabytes. This is configuration-and-state storage, not a file store — keep rows small and lean.
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: App Data API reference.