Quickstart
This walkthrough takes you from nothing to a working Voshi app: the three HTTPS endpoints Voshi calls, plus one API call that pushes a grade back to the LMS.
What you'll need
- A place to run an HTTPS web server that's reachable from the public internet (Voshi validates that each URL you register is public
https://). - A Voshi builder account — contact the MyEducator team if you don't have dashboard access yet.
Open the Voshi dashboard, go to Apps → Register App, and fill in:
- App Name — shown to instructors when they add your app to a course.
- Callback URL — the HTTPS endpoint you're about to build (e.g.
https://myapp.example.com/launch_receiver). - Description / Logo URL — optional.
Your app also needs a Provision URL and a Locations URL (steps 3 and 4). Set both on the app's Profile tab after registering — see Registering your app.
The response shows your API key exactly once — copy it now and store it securely, like a password. It authenticates your calls to the grades and management APIs.
The API key is shown only once. If you lose it, rotate it with the current key, or contact the MyEducator team if you no longer have it.
When someone opens your app from an LMS, Voshi sends their browser to your callback URL with a form POST containing a single field, launch_data — a signed JWT carrying everything about the launch.
A minimal receiver that verifies the JWT and starts a session:
- Python (Flask)
- Node (Express)
# pip install flask pyjwt cryptography
from flask import Flask, request, session
import jwt
from jwt import PyJWKClient
JWKS_URL = "https://api.link.voshi.com/lti13/v1/jwks"
jwks = PyJWKClient(JWKS_URL) # fetches and caches Voshi's public keys by kid
app = Flask(__name__)
app.secret_key = "replace-with-a-real-secret"
@app.post("/launch_receiver")
def launch():
token = request.form["launch_data"]
# Verify the signature and expiry — never trust the claims before this.
signing_key = jwks.get_signing_key_from_jwt(token)
data = jwt.decode(token, signing_key.key, algorithms=["RS256"])
# Trade the one-time launch token for your own session.
session["voshi_user_id"] = data["user"]["id"]
session["voshi_member_id"] = data["user"]["member"] # this person in this course
session["groups"] = data["user"]["groups"] # e.g. ["instructor"]
session["course_id"] = data["context"]
session["launch_id"] = data["launch_id"] # needed for grade passback
session["can_grade"] = data["grade_passback"]
location = data["location"]
if location["type"] == "assessment":
return f"Quiz time! You launched: {location['label']}"
return f"Welcome to {location['label']}"
// npm install express jose express-session
import express from 'express'
import session from 'express-session'
import { createRemoteJWKSet, jwtVerify } from 'jose'
const JWKS_URL = 'https://api.link.voshi.com/lti13/v1/jwks'
const jwks = createRemoteJWKSet(new URL(JWKS_URL)) // caches keys by kid
const app = express()
app.use(express.urlencoded({ extended: false }))
app.use(session({ secret: 'replace-with-a-real-secret', resave: false, saveUninitialized: false }))
app.post('/launch_receiver', async (req, res) => {
// Verify the signature and expiry — never trust the claims before this.
const { payload: data } = await jwtVerify(req.body.launch_data, jwks, {
algorithms: ['RS256'],
})
// Trade the one-time launch token for your own session.
req.session.voshiUserId = data.user.id
req.session.voshiMemberId = data.user.member // this person in this course
req.session.groups = data.user.groups // e.g. ['instructor']
req.session.courseId = data.context
req.session.launchId = data.launch_id // needed for grade passback
req.session.canGrade = data.grade_passback
const location = data.location
if (location.type === 'assessment') {
res.send(`Quiz time! You launched: ${location.label}`)
} else {
res.send(`Welcome to ${location.label}`)
}
})
app.listen(3000)
Verify, read the claims, serve your app. See Verifying the launch JWT for the full security checklist — every endpoint below verifies launch_data the same way.
Instructors can only place destinations your app tells Voshi about. When one is placing content, Voshi POSTs a signed launch_data to your Locations URL and shows them what you return, keyed by an external ID of your own choosing:
- Python (Flask)
- Node (Express)
@app.post("/voshi/locations")
def voshi_locations():
verify_launch_data(request.form["launch_data"]) # same verification as a launch
return {"locations": {
"ch3_quiz": {
"type": "assessment", # the only type that can be graded
"label": "Chapter 3 Quiz",
"description": "20 questions on normal distributions.",
"points": 50, # gradebook column value
},
"syllabus": {"type": "content", "label": "Course Syllabus"},
}}
app.post('/voshi/locations', async (req, res) => {
await verifyLaunchData(req.body.launch_data) // same verification as a launch
res.json({ locations: {
ch3_quiz: {
type: 'assessment', // the only type that can be graded
label: 'Chapter 3 Quiz',
description: '20 questions on normal distributions.',
points: 50, // gradebook column value
},
syllabus: { type: 'content', label: 'Course Syllabus' },
}})
})
Build this list from your own data and it stays current on its own. See Serving your locations.
Before the first launch of a course reaches you, Voshi asks your app to set that course up — and the same for each location placed in it. Answer 204 immediately, then call the URLs from the provision claim when you're done:
- Python (Flask)
- Node (Express)
@app.post("/voshi/provision")
def voshi_provision():
data = verify_launch_data(request.form["launch_data"])
queue_setup(data) # do the work in the background
return "", 204 # answer first
def queue_setup(data):
headers = {"Authorization": f"Bearer {data['api']['token']}"}
# The course first — a location cannot be provisioned before its course.
if "context" in data["provision"]:
requests.put(data["provision"]["context"], headers=headers).raise_for_status()
if "location" in data["provision"]:
requests.put(data["provision"]["location"], headers=headers).raise_for_status()
app.post('/voshi/provision', async (req, res) => {
const data = await verifyLaunchData(req.body.launch_data)
res.status(204).end() // answer first
queueSetup(data) // do the work in the background
})
async function queueSetup(data) {
const headers = { Authorization: `Bearer ${data.api.token}` }
// The course first — a location cannot be provisioned before its course.
if (data.provision.context) {
await fetch(data.provision.context, { method: 'PUT', headers })
}
if (data.provision.location) {
await fetch(data.provision.location, { method: 'PUT', headers })
}
}
Until those calls land, launches for that course wait — and its context and location storage rows stay closed. See How provisioning works.
The location claim tells you which destination was launched — your own extid, Voshi's id, plus its type and label. There is no params object; configuration lives in your own data, keyed on the extid.
Switch on location.extid to decide what to render:
location = data["location"]
if location["extid"] == "ch3_quiz":
quiz = Quiz.query.filter_by(extid=location["extid"]).one()
If the launch arrived with "grade_passback": true, you can push a score to the LMS gradebook. Authenticate with your API key:
- Python
- Node
import requests
resp = requests.post(
"https://api.link.voshi.com/ltiaas/v1/grades",
headers={"Authorization": "Bearer ltiaas_myappid_mysecret"},
json={
"launch_id": session["launch_id"],
"score": 0.85, # a fraction from 0.0 to 1.0
"comment": "Nice work!",
},
)
resp.raise_for_status()
print(resp.json()["sync_status"]) # "synced" if it reached the gradebook
const resp = await fetch('https://api.link.voshi.com/ltiaas/v1/grades', {
method: 'POST',
headers: {
Authorization: 'Bearer ltiaas_myappid_mysecret',
'Content-Type': 'application/json',
},
body: JSON.stringify({
launch_id: req.session.launchId,
score: 0.85, // a fraction from 0.0 to 1.0
comment: 'Nice work!',
}),
})
const grade = await resp.json()
console.log(grade.sync_status) // "synced" if it reached the gradebook
See Grade passback for retry behavior and sync statuses.
Your app starts in draft status. Draft apps can't be placed in courses or launched from an LMS, so once all three endpoints and your grade passback work, contact the MyEducator team to activate your app. After activation, instructors can add it to their courses and you can test end-to-end from a real LMS.
The dashboard's Test tab exercises your Locations URL, your Callback URL, and grade passback against fabricated claims before any of that — the quickest way to check your JWT verification. Trial requests carry test: true and record nothing.
Where to next
Every field in the launch JWT, explained.
Persist state per course, placement, and student — no database required.
Course setup, the two levels, and what students see until it's done.
The destinations instructors place, and how your app declares them.