Receiving provision requests
The provisioning receiver is the second HTTPS endpoint you build — the Provision URL you give when registering your app. Voshi calls it when a course, or a location in a course, needs to be set up in your app. See How provisioning works for when that happens.
The request Voshi sends you
POST /provision HTTP/1.1
Host: myapp.example.com
Content-Type: application/x-www-form-urlencoded
launch_data=eyJhbGciOiJSUzI1NiIsImtpZCI6...
The same single launch_data field a launch carries, signed the same way. Verify the signature exactly as you verify a launch — see Verifying the launch JWT.
Unlike a launch, this POST comes from Voshi's server, not through anyone's browser. Nothing is rendered from your response — the person who triggered setup is watching Voshi's "Provisioning" page, not your app.
Requirements for your endpoint
- Public HTTPS. An absolute
https://URL on a publicly-routable host, validated when you register it and re-checked every time Voshi calls it. - Accept a form POST.
Content-Type: application/x-www-form-urlencoded, one field namedlaunch_data. - Answer
204 No Contentpromptly — within a few seconds. Voshi gives up after 60 seconds, and an instructor is waiting on the other end. Do the setup work after you respond. - You don't need to de-duplicate. Voshi sends your app exactly one provisioning request per level — see You are asked exactly once.
You are asked exactly once
Voshi will only ever send your app one provisioning request for a given level. Concurrency on the way in — two instructors opening a brand-new course at the same moment, a student and an instructor clicking at once — is resolved by Voshi before it reaches you. Your app does not have to de-duplicate provisioning requests or guard against a second one arriving for the same course or location.
The flip side is that there is no retry: if your app drops the request or your setup job dies, Voshi does not ask again, and the course stays on the "Provisioning" page until you call the provision URL. See Who triggers it, and who can finish it.
Which request is which
A request is identified by where it arrives. A POST to your Callback URL is always a person opening your app. A POST to your Provision URL is course setup, with a smaller set of claims — no user is doing coursework yet.
Claims
{
"iat": 1751470000,
"exp": 1751477200,
"iss": "https://api.link.voshi.com",
"api": {
"domain": "api.link.voshi.com",
"token": "8f2c1a..."
},
"app": "I4ppXy",
"context": "IcT91mBxze",
"organization": {
"title": "Example University",
"issuer": "I1ssU3r",
"client": "Icl13nt",
"deployment": "Id3pL0yMnt"
},
"user": {
"id": "In4kDp7yZq",
"member": "Im8mBr42",
"groups": ["instructor"],
"given_name": "Ada",
"family_name": "Lovelace",
"full_name": "Ada Lovelace",
"email": "ada@example.edu"
},
"course": {
"id": "IcT91mBxze",
"name": "Intro to Computing",
"label": "CS101"
},
"location": {
"id": "Il0c8n",
"extid": "quiz1",
"type": "assessment",
"label": "Quiz 1"
},
"resource_link": "Ir3sLnk42",
"provision": {
"context": "https://api.link.voshi.com/lti13/v1/contexts/IcT91mBxze/apps/I4ppXy/provision",
"location": "https://api.link.voshi.com/lti13/v1/contexts/IcT91mBxze/apps/I4ppXy/locations/ext:quiz1/provision"
},
"parent_context": "Ic0pI3dFr0m",
"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",
"parent_context": "https://api.link.voshi.com/lti13/v1/contexts/Ic0pI3dFr0m/apps/I4ppXy/data",
"parent_location": "https://api.link.voshi.com/lti13/v1/contexts/Ic0pI3dFr0m/apps/I4ppXy/locations/ext:quiz1/data"
}
}
provisionobjectWhat needs setting up. Only the levels being asked for are present — context, location, or both. Each value is the URL you call when that level is done. Treat the URLs as opaque and use them as given.
contextstringThe course being set up — the same ID that appears in the contexts/… segment of the storage URLs.
locationobjectThe location the triggering launch targeted, in the same shape as the Launch Data claim: id, extid, type, label. null when the launch targeted no location — in which case provision.location, storage.location, and storage.parent_location are absent or null too.
parent_contextstringSet when this course was copied from another course — the ID of the course it came from. null for an original course. See Copied courses.
storageobjectThe app data rows this setup opens, plus the matching rows of the course this one was copied from.
The remaining claims — iat, exp, iss, api, app, course, organization, user, resource_link — mean exactly what they mean in Launch Data. user is the person whose launch triggered setup; in the normal flow that is the instructor placing your app, so their groups reflect course staff and the user name and email fields are populated (they follow the same course-staff-only rule as a launch).
What a provision request does not carry
No launch_id, time, or grade_passback. Nobody is doing coursework yet and there is no launch to grade, so there is nothing to store a grade against.
What your app must do
Verify the JWT, note what the provision claim is asking for, and return 204 No Content right away. Don't hold the response while you work.
In the background, do whatever "this course exists in my app" and "this assignment exists in my app" mean for you.
Call the URL for each level as that level finishes. This is what opens the level up — until it lands, launches keep waiting.
@app.post("/provision")
def provision_receiver():
data = verify_launch_jwt(request.form["launch_data"]) # see Verifying the JWT
queue_setup(data) # background — see below
return "", 204 # answer first, work later
Marking a level ready
PUT the URL from the provision claim, authenticated with the request's own api.token:
- curl
- Python
- Node
curl -X PUT "https://api.link.voshi.com/lti13/v1/contexts/IcT91mBxze/apps/I4ppXy/provision" \
-H "Authorization: Bearer 8f2c1a..." \
-H "Content-Type: application/json" \
-d '{"weeks": 14, "term": "fall"}'
import requests
def finish_setup(data):
token = data["api"]["token"]
headers = {"Authorization": f"Bearer {token}"}
# The course first — a location cannot be provisioned before its course.
if "context" in data["provision"]:
requests.put(
data["provision"]["context"],
headers=headers,
json={"weeks": 14, "term": "fall"}, # optional: seeds the course storage row
).raise_for_status()
if "location" in data["provision"]:
requests.put(
data["provision"]["location"],
headers=headers,
).raise_for_status()
async function finishSetup(data) {
const headers = {
Authorization: `Bearer ${data.api.token}`,
'Content-Type': 'application/json',
}
// The course first — a location cannot be provisioned before its course.
if (data.provision.context) {
await fetch(data.provision.context, {
method: 'PUT',
headers,
// optional: seeds the course storage row
body: JSON.stringify({ weeks: 14, term: 'fall' }),
})
}
if (data.provision.location) {
await fetch(data.provision.location, { method: 'PUT', headers })
}
}
Both calls answer 204 No Content.
The token has to belong to course staff — an instructor, manager, assistant, or mentor. Provisioning triggered by an instructor placing your app always satisfies that; a 403 here means the launch that triggered setup came from a student, who can't complete it, which needs a platform-side reset. See Who triggers it, and who can finish it.
bodyobjectOptional. Whatever JSON you send becomes the initial contents of that level's storage row — the same object a later GET on storage.context or storage.location returns. Send no body to leave the row as it is.
Order matters
A location cannot be provisioned before its course. PUTting a provision.location URL while the course's provision.context is still unfinished is rejected with App … must be provisioned before its locations are provisioned. If you want to set up all of your locations at once, call provision.context first, then loop over the location URLs.
It doesn't have to be synchronous
You may call a provision URL any time — from a background job, or long after the request that prompted it. You may also provision locations Voshi hasn't asked about yet, which is how you set a whole course up in one pass.
Launches for a level wait until its provision call lands, so don't leave it for later. The instructor watching the "Provisioning" page has no way to retry from the browser — if your setup job dies, their course stays stuck until you call the URL.
Copied courses
When an instructor copies a course — a new term from last term's shell — the new course provisions from scratch. But the request tells you where it came from:
parent_context— the ID of the course this one was copied from.storage.parent_context/storage.parent_location— the previous course's data rows.
Read those rows to carry the instructor's earlier configuration forward, then write it into the new course as you provision. Voshi does not copy app data between courses; this claim is how you do it yourself, and it is the only point at which you're handed the previous course's rows.
def carry_forward(data):
parent = data["storage"]["parent_context"]
if not parent:
return {} # an original course — start from defaults
resp = requests.get(parent, headers={"Authorization": f"Bearer {data['api']['token']}"})
if resp.status_code in (403, 422):
return {} # nothing to copy — fall back to defaults
resp.raise_for_status()
return resp.json()
All three are null for an original course. Reading them uses the launching user's token, so it only succeeds if that earlier course was itself provisioned and the user still has a role in it — treat 403 and 422 as "nothing to copy" rather than an error.