Skip to main content
Version: 2026-08-25 (archived)

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.

ScopeOne row per...Typical use
contextcourseCourse-wide settings: term dates, module visibility, instructor preferences
locationplacement (location × course)Per-assignment configuration: due date overrides, question pool selection
memberuser × courseThe student's course-wide state: profile choices, cumulative progress
member_locationuser × placementThe 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>
note

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:

Rowstudentinstructor / manager / assistant / mentor
contextreadread + write
locationreadread + write
memberread + write, their own onlyread + write, for any member of the course
member_locationread + write, their own onlyread + write, for any member of the course

Two consequences worth designing around:

  • Students cannot write the shared rows. A PUT or PATCH on context or location with a student's token returns 403. Course-wide state that students need to change has to live somewhere else — their own member row, 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_location row 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.

note

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 PATCH so 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 context and location row 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.