Skip to main content
Version: 2026-10-13 (current)

Receiving provision requests

The provisioning receiver is the second HTTPS endpoint you build — the Provision URL you give when registering your tool. Voshi calls it when a course, or a location in a course, needs to be set up in your tool. 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 tool.

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 tool one provisioning request per level, and another only after about three hours without an answer — see You are asked once.

You are asked once​

Voshi sends your tool 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 tool does not have to de-duplicate provisioning requests or guard against a second one arriving while the first is in progress.

There is one retry, and it is slow: the request's token is good for 3 hours. If that passes without your finish call, Voshi treats the job as dead and sends a new request, with a new token, at the next launch of that level. Until then the course stays on the "Provisioning" page. So a finish call that fails with 401 after a long job means the token has expired; answer the next request instead of retrying the old token, and make your setup idempotent — a course you half-created the first time is set up again by the second request.

Which request is which​

A request is identified by where it arrives. A POST to your Callback URL is always a person opening your tool. 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": "vlt_8f2c1a...",
"expires": 1751480800,
"idle": null
},
"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"
},
"files": {
"context": "https://api.link.voshi.com/lti13/v1/contexts/IcT91mBxze/apps/I4ppXy/files",
"location": "https://api.link.voshi.com/lti13/v1/contexts/IcT91mBxze/apps/I4ppXy/locations/ext:quiz1/files",
"parent_context": "https://api.link.voshi.com/lti13/v1/contexts/Ic0pI3dFr0m/apps/I4ppXy/files",
"parent_location": "https://api.link.voshi.com/lti13/v1/contexts/Ic0pI3dFr0m/apps/I4ppXy/locations/ext:quiz1/files"
}
}
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 tool 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 storage 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.

filesobject

The files kept beside each of those four rows, with the same four keys and the same null rules — for carrying an instructor's uploaded material forward along with their settings.

The remaining claims — iat, exp, iss, api, app, course, organization, user, resource_link — mean exactly what they mean in Launch Data, with one difference: api.token is a provisioning token, fixed at 3 hours (idle is null) and never extended. user is the person whose launch triggered setup; in the normal flow that is the instructor placing your tool, 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, grade, or version. Nobody is doing coursework yet and there is nobody to grade, so there is no score to publish.

What your tool 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 works whoever triggered setup, for 3 hours. A 401 here means those 3 hours have passed: Voshi will send a new request at the next launch, so stop and answer that one. 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 Tool … 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 storage 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 request's own token, which is allowed to read the previous course's rows for exactly this purpose; it still fails if that earlier course was never provisioned — treat 403 and 422 as "nothing to copy" rather than an error. The files claim carries the previous course's file lists the same way.