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://link.voshi.com/lti13/v1/contexts/IcT91mBxze/apps/I4ppXy/data",
"location": "https://link.voshi.com/lti13/v1/contexts/IcT91mBxze/apps/I4ppXy/locations/Il0c8n/data",
"member": "https://link.voshi.com/lti13/v1/contexts/IcT91mBxze/members/In4kDp7yZq/apps/I4ppXy/data",
"member_location": "https://link.voshi.com/lti13/v1/contexts/IcT91mBxze/members/In4kDp7yZq/apps/I4ppXy/locations/Il0c8n/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 rows are created automatically on first launch — the URLs in the claim always point at rows that exist.
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 who the token belongs to:
- Members of the course can read and write the shared
contextandlocationrows. - Only the user themself can access their
memberandmember_locationrows — one student can never touch another student's rows. - Your app's builders (members of the app in the dashboard) can access any row belonging to the app, which is handy for debugging.
The shared context and location rows are writable by any member of the course — including students. Don't store anything there that a student shouldn't be able to change; keep instructor-only state guarded by your own role checks, or in your own database.
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. - Course data rolls forward. When a school starts a new term and the LMS creates a fresh course from an old one, Voshi copies the previous course's
contextandlocationdata into the new rows (once, on first launch). Instructor configuration survives the semester boundary.memberandmember_locationrows do not roll forward — each student starts clean. - 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.