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

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.

note

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 named launch_data.
  • Answer 204 No Content promptly — 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"
}
}
provisionobject

What 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.

provision fields
contextstring

Present when your app in this course needs setting up.

locationstring

Present when this location in this course needs setting up.

contextstring

The course being set up — the same ID that appears in the contexts/… segment of the storage URLs.

locationobject

The 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_contextstring

Set when this course was copied from another course — the ID of the course it came from. null for an original course. See Copied courses.

storageobject

The app data rows this setup opens, plus the matching rows of the course this one was copied from.

storage fields
contextstring

The course row you are provisioning.

locationstring

The location row you are provisioning.

parent_contextstring

The same row in the course this one was copied from — null for an original course.

parent_locationstring

The same location's row in the course this one was copied from — null for an original course.

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 and answer 204

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.

Set the levels up

In the background, do whatever "this course exists in my app" and "this assignment exists in my app" mean for you.

PUT each provision URL

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 -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"}'

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.

bodyobject

Optional. 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.

warning

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.