Your own server
Most tools are hosted by Voshi: the builder writes browser code, and Voshi runs everything else. A self-hosted tool runs on your own server instead. Voshi still handles the LMS, the placement, the students, and the gradebook, but it hands each launch to your server over HTTPS, and your server does the rest.
This section is the contract for that: the endpoints you build, what arrives at them, how to verify it, and the REST APIs for storage and grades. If you are building a hosted tool, none of it applies to you — everything in it is done for you by the Voshi API.
When to self-host
Choose your own server when the tool needs something that cannot live in the browser:
- A backend of your own — an existing product, a database with its own schema, server-side logic.
- Grading that must not be readable off the tool's source, computed and published from your server.
- A catalog of locations that changes dynamically — locations generated per placement, or served from your own data.
- Grading outside the launch — a background job, an instructor's roster view, scoring work hours after the student left.
Everything else — identity, storage, files, grades during the launch — a hosted tool already has, with no server to run.
Hosted or self-hosted
The choice is made at registration and cannot be changed afterwards. It decides whether your tool has endpoints of its own at all.
| Voshi Hosted | Self Hosted | |
|---|---|---|
| Where your code runs | Voshi's CDN, in the learner's browser | Your own server |
| How you build it | AI Builder, or upload a folder | However you like |
| Locations | Declared in the dashboard or manifest.json | Served by your Locations URL on demand |
| Course setup | Automatic | Your Provision URL is called |
| Launches | Your element is constructed with the Voshi API | A signed launch_data JWT is posted to your Callback URL |
| Storage and grades | api.storage, api.submitGrade() | REST calls with the launch's token, or your API key |
| API key | None | One, shown once |
The three endpoints you build
Voshi reaches your tool at three URLs. All three must be public HTTPS, and all three receive a form POST with a single signed launch_data field that you verify the same way.
| URL | Called when | Docs |
|---|---|---|
| Callback URL | Someone opens your tool from a course. Arrives through their browser. | Receiving launches |
| Provision URL | A course, or a location in it, needs setting up in your tool. Server to server. | Receiving provision requests |
| Locations URL | An instructor is placing your tool and Voshi needs your list of destinations. Server to server. | Serving your locations |
Only the Callback URL is strictly required to receive a launch — but a tool without a Locations URL can't be placed in a course, and a tool without a Provision URL can't finish course setup, so a live tool needs all three.
Register in the dashboard
In the dashboard, fill in the Tool Name and choose Self Hosted under Hosting. This cannot be changed later.
Registration shows your API key exactly once — copy it now and store it securely, like you would a password. Its one job is letting your server publish grades with no launch session.
Open your tool's Settings tab and fill in Callback URL, Provisioning URL, and Locations URL. They're blank at registration on purpose — the server they point at usually doesn't exist yet when you register.
You can edit the name, all three URLs, the description, and the logo at any time on the Settings tab. Changes apply immediately — including to placements instructors have already made. See Managing your tool.
Endpoint URL rules
Each of the three URLs is validated when you set it and re-checked every time Voshi calls it:
- Must be an absolute
https://URL (nohttp://, no relative paths). - Must point at a publicly-routable host — URLs that resolve to private, loopback, or link-local addresses are rejected. Your local machine can't receive launches; use a tunnel with a public HTTPS hostname or a deployed environment during development.
A minimal self-hosted tool
When someone opens your tool, 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. Verify it, start your own session, and render.
- 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["grade_url"] = data["grade"]["submit"] # None if this placement can't be graded
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.gradeUrl = data.grade.submit // null if this placement can't be graded
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)
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 tool 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' },
}})
})
Before the first launch of a course reaches you, Voshi asks your tool 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. See How provisioning works.
Every launch carries a grade.submit URL — or null, when that placement has no gradebook column. POST the score there; your API key authorizes it with no launch session:
- Python
- Node
import requests
grade_url = session["grade_url"]
if grade_url: # None when this placement can't be graded
resp = requests.post(
grade_url,
headers={"Authorization": "Bearer ltiaas_mytoolid_mysecret"},
json={
"score": 0.85, # a fraction from 0.0 to 1.0
"comment": "Nice work!",
},
)
resp.raise_for_status()
attempt = resp.json()
print(attempt["status"]) # "success" if it reached the gradebook
const gradeUrl = req.session.gradeUrl
if (gradeUrl) { // null when this placement can't be graded
const resp = await fetch(gradeUrl, {
method: 'POST',
headers: {
Authorization: 'Bearer ltiaas_mytoolid_mysecret',
'Content-Type': 'application/json',
},
body: JSON.stringify({
score: 0.85, // a fraction from 0.0 to 1.0
comment: 'Nice work!',
}),
})
const attempt = await resp.json()
console.log(attempt.status) // "success" if it reached the gradebook
}
A rejected score comes back 200 with status: "failed" and the reason in data.error — check it. See Grade passback.
Testing your tool
The dashboard's Sandbox tab launches your tool in a trial course of its own, before any LMS is involved and while your tool is still in draft status. These are real launches: Voshi plays the part of the LMS, your Locations URL is asked what you offer through the ordinary picker, your Provision URL is called and waited on, your Callback URL receives a properly signed launch_data, real storage rows are created, and the Sandbox has a gradebook of its own that your grade submissions land in.
- New Link opens your tool's content picker, served from your Locations URL, and what you pick becomes a test link.
- Launch any test link as a student or as an instructor. Each role is a separate person in the trial course with its own data.
- Reset on a test link throws away everything stored for that link, so the next launch provisions it from scratch — the way to re-test your Provision URL.
- The Sandbox's Launches and Grades tabs show what Voshi recorded.
A trial launch signs you into the browser as the test student or instructor, not as yourself. Open launches in a new window, and expect to sign back into the dashboard afterwards.
Tool status
| Status | Meaning |
|---|---|
draft | The initial status. The tool cannot be placed in real courses or launched from an LMS — it's invisible to instructors. You can still test it from the Sandbox. |
active | Live: instructors can place it, launches are forwarded to your Callback URL. |
suspended | Disabled by the platform team. Launches and API calls are rejected. |
Only the Voshi platform team can change your tool's status. The path to going live is:
- Build your three endpoints and your grade flow against these docs.
- Exercise them from the Sandbox tab.
- Contact the MyEducator team to activate your tool.
- Test from a real LMS course — place a location, launch as an instructor and as a student, and verify grades land in the gradebook.
Your API key
Only a self-hosted tool has one. It has the form:
ltiaas_<tool-id>_<secret>
- It's shown once, at registration. Voshi stores only a hash of it and cannot show or recover it — the dashboard displays a hint (
ltiaas_…last4) so you can tell which key is live. - Keep it server-side only.
- Its one use is publishing a grade from your own server, long after the launch session is gone. It does not configure your tool, and it is not a general-purpose credential.
- It is not involved in launch verification — launches are verified against Voshi's public keys. Nor is it what provisioning calls use; those authenticate with the request's own
api.token.
Rotating or replacing your API key
Rotate the key from the Settings tab in the dashboard. A new key is issued and shown once, exactly as at registration.
The old key stops working immediately — there is no overlap window. Every deployed instance of your tool still using it starts getting 401 the moment you rotate, so roll the new key out promptly, or accept a gap in server-side grading.
Rotation is also the only recovery for a lost key, and the right response to a leaked one.
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 tool declares them.