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

External hosting

Most tools are hosted by Voshi: the builder writes browser-oriented Javascript, and Voshi hosts everything. In contrast, an externally 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.

External hosting is an exception​

Hosting is not a choice on the New Tool form. Voshi hosts tools because a tool that runs on Voshi's infrastructure is one Voshi can vouch for to the schools that adopt it: it is reviewed before it reaches students, it stores student work where Voshi can see how it is kept, and it cannot reach out to anywhere else. A server Voshi does not run can give none of that for free, so external hosting is granted case by case, where a tool genuinely needs it:

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

An externally hosted tool comes with increased review and periodic checks. Voshi reviews it more closely before it goes live, and checks on it periodically afterwards, to confirm that it keeps meeting the security, privacy, and other expectations the educational space places on anything that handles student work. Expect to be asked about where data lives, who can reach it, and how the server is kept patched, and expect those questions to come around again.

To request it, contact the MyEducator team with what the tool is and why it needs its own server. If it is approved, your tool is set up as external, and the differences below apply.

Hosted or external​

Voshi HostedExternal
Where your code runsVoshi's CDN, in the learner's browserYour own server
How you build itAI Builder, or upload a folderHowever you like
LocationsRecorded in the dashboard (the Locations tab, kept in step by the AI Builder)Served by your Locations URL on demand
Course setupAutomaticYour Provision URL is called
LaunchesYour element is constructed with the Voshi APIA signed launch_data JWT is posted to your Callback URL
Storage and gradesapi.storage, api.submitGrade()REST calls with the launch's token, or your API key
API keyNoneOne, shown once
VersionsOne per publish, each reviewedOne, standing for the tool; its visibility is what is reviewed
DashboardAI Builder, VersionsRoutes, Status

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.

URLCalled whenDocs
Callback URLSomeone opens your tool from a course. Arrives through their browser.Receiving launches
Provision URLA course, or a location in it, needs setting up in your tool. Server to server.Receiving provision requests
Locations URLAn 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.

Setting up in the dashboard​

Open your tool

Once the MyEducator team has set your tool up as external, it appears under Tools in the dashboard with a Routes page in place of the AI Builder and a Status page in place of Versions.

Issue your API key

On the Settings tab, press Rotate Key. The new key is shown 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.

Register your three URLs

Open the Routes page and fill in Callback URL, Provisioning URL, and Locations URL. They're blank to begin with on purpose — the server they point at usually doesn't exist yet.

You can edit the name, description, and logo on the Settings tab, and the three URLs on Routes, at any time. Changes apply immediately — including to placements instructors have already made. See Managing your tool.

Endpoint URL rules​

Each of the three URLs must be an absolute https:// URL (no http://, no relative paths); that is checked when you save it. Each must also point at a publicly-routable host, which is checked every time Voshi calls it: a URL that resolves to a private, loopback, or link-local address is refused at that moment, and the instructor or launch sees the error. Your local machine can't receive launches; use a tunnel with a public HTTPS hostname or a deployed environment during development.

A minimal external tool​

Build your launch receiver

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.

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

See Verifying the launch JWT for the full security checklist — every endpoint below verifies launch_data the same way.

Serve your locations

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:

@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"},
}}

See Serving your locations.

Set courses up when Voshi asks

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:

@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()

Until those calls land, launches for that course wait. See How provisioning works.

Send a grade back

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:

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

A rejected score comes back 200 with status: "failed" and the reason in data.error — check it. See Grade passback.

Optional: the Voshi API client on a Node server​

Everything above calls the REST APIs directly, which works from any language. If your server runs on Node, there is a shortcut: the same Voshi API client a hosted tool is handed runs on a server too, and wraps the storage, files, and grade calls for you. It is one dependency-free ES module, published to npm as voshi-api:

npm install voshi-api

Construct one from the launch data after you have verified the JWT:

import { VoshiApi, VoshiPermissionError } from 'voshi-api'

app.post('/launch_receiver', async (req, res) => {
const data = await readLaunchData(req.body.launch_data) // verified claims
req.session.launch = data // keep the claims, not the instance

const api = new VoshiApi(data)
const saved = await api.storage.memberLocation.get() // {} on a first visit
res.send(render(data.location, saved))
})

app.post('/finish', async (req, res) => {
const api = new VoshiApi(req.session.launch)
try {
if (api.canSubmitGrade) {
await api.submitGrade({ score: 0.85, maxScore: 10, comment: 'Nice work!' })
}
res.sendStatus(204)
} catch (err) {
if (err instanceof VoshiPermissionError) res.sendStatus(403)
else throw err
}
})

What you get is what the API section documents — api.user, api.isStaff, api.location, api.storage.<scope>.get / set / update / patch, api.storage.<scope>.files, api.submitGrade(), and the thrown error classes — minus api.assets, which only the hosted launch page provides. Three things to know on a server:

  • It acts as the launching user, with the launch's token. It cannot use your API key, so it is for work during the launch: a score published while the student is still in the tool, their work read and saved as they go. Grading hours later is still a POST to the stored grade.submit URL with your key, as above.
  • Construct it per request from the stored claims. The launch token ends after 12 hours idle; a VoshiAuthenticationError means the user needs a fresh launch.
  • Node 18 or later, for the global fetch, Blob, and FormData. Each release is also served as a file, for Deno or a browser that imports it directly: https://api.link.voshi.com/static/ltiaas/dist/voshi-api.1.0.0.js. Name the version you want; there is no unversioned URL, so what you import never changes under you. See Versions of the client.

Optional: the Voshi API client on a Python server​

The same client exists for Python, published to PyPI as voshi-api, with the same storage, files, and grade methods under Python names (api.storage.member_location, api.submit_grade(...), api.can_submit_grade). It needs Python 3.9 and requests:

pip install voshi-api
from voshi_api import VoshiApi, VoshiPermissionError

@app.post('/launch_receiver')
def launch_receiver():
data = read_launch_data(request.form['launch_data']) # verified claims
session['launch'] = data # keep the claims, not the instance

api = VoshiApi(data)
saved = api.storage.member_location.get() # {} on a first visit
return render(data['location'], saved)

@app.post('/finish')
def finish():
api = VoshiApi(session['launch'])
try:
if api.can_submit_grade:
api.submit_grade(score=0.85, max_score=10, comment='Nice work!')
return '', 204
except VoshiPermissionError:
abort(403)

Every call is synchronous and raises a VoshiApiError subclass on failure, the same family as the JavaScript client's. Two things differ from JavaScript, because Python has to: update() removes a key whose value is voshi_api.REMOVE (JavaScript's undefined), and files go in as bytes or an open file and come out as bytes. The three notes above apply unchanged. The Python package's major and minor version track the JavaScript client's, so 1.2.x on PyPI mirrors 1.2 on npm.

Testing your tool​

The dashboard's Sandbox tab launches your tool in a trial course of its own, before any LMS is involved and before the tool is visible to anyone else. These are real launches: Voshi plays the part of the LMS, your Locations URL is asked what you offer through the ordinary Voshi deep linking dialog, 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 the Voshi deep linking dialog, listing what your Locations URL serves, 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.
note

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.

Going live​

An externally hosted tool has no versions to publish — your server is the one version there is — but it is reviewed and found the same way a hosted tool's versions are, through a single standing version whose visibility you set on the Status page:

VisibilityMeaning
PrivateWhere every tool starts. Only courses you teach yourself can add it, so you can pilot it with your own students.
UnlistedInstructors add it with a sharing code. Not listed in the Voshi catalog.
PublicListed in the Voshi catalog, where any instructor can find it.

Change Status to unlisted or public sends the tool for review; it shows as Pending review until an administrator approves it, and cannot launch anywhere but your own courses meanwhile. Once approved it is available to instructors. The path, then:

  1. Build your three endpoints and your grade flow against these docs.
  2. Exercise them from the Sandbox tab, and in a real course of your own if you have one (a private tool can be added to a course you teach, with a code pinned to pilot).
  3. On Status, change the visibility to unlisted or public, with notes for the reviewer.
  4. After approval, test from a real LMS course — place a location, launch as an instructor and as a student, and verify grades land in the gradebook.

The review is where the increased scrutiny of external hosting lands: expect questions about your server, and expect to be asked again from time to time after approval.

The tool itself also has a Status on the Settings tab: Active, Archived (you put it away — it does not launch or take placements, and you can unarchive it), or Suspended (Voshi disabled it, which only Voshi can lift).

Your API key​

Only an externally hosted tool has one. It has the form:

ltiaas_<tool-id>_<secret>
  • It's shown once, when it is issued. 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 with Rotate Key on the Settings tab. A new key is issued and shown once.

warning

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​