Receiving launches
The launch receiver is the single HTTPS endpoint you build in your app — the Callback URL you provide when registering your app. When a student (or instructor) opens your app from the LMS, Voshi sends the user's browser to your endpoint with an HTTP POST describing the launch.
The request Voshi sends you
Your callback URL receives a form POST with a single field: a signed JWT carrying the data from this launch.
POST /launch_receiver HTTP/1.1
Host: myapp.example.com
Content-Type: application/x-www-form-urlencoded
launch_data=eyJhbGciOiJSUzI1NiIsImtpZCI6...
Verify its signature and decode it to read the launch — see Verifying the launch JWT.
Because the JWT arrives through the browser rather than from a Voshi server, its signature is the only thing standing between you and a forged launch. Never trust the claims until you've verified the signature.
Requirements for your endpoint
- Public HTTPS. The callback URL must be an absolute
https://URL on a publicly-routable host. Voshi validates this when you register and again at every launch — URLs pointing at private or loopback addresses are rejected. - Accept a form POST.
Content-Type: application/x-www-form-urlencoded, one field namedlaunch_data. - Be fast. The user is sitting in front of a blank-ish redirect page while your endpoint responds. Do the minimum — verify, set a session cookie, render or redirect.
What happens after the POST
From this point, the user is simply navigating your app, so show whatever they should see: render your page directly, or redirect them onward based on the location that was launched.
A typical receiver:
- Verify the JWT (signature + expiry).
- Create your own session for the user — the launch token is a one-time entry ticket, not a session credential. Don't accept the same token twice.
- Store what you'll need later:
launch_id(required to send a grade),user.id, thestoragerow URLs, and theapitoken if you plan to use the App Data API. - Provision the school, course, user, enrollment, and placement if you haven't seen them before — see Just-in-time provisioning.
- Switch on
location.id(or itsparams) to decide which screen to render.
@app.post("/launch_receiver")
def launch():
data = verify_launch_jwt(request.form["launch_data"]) # see Verifying the JWT
session["voshi_user_id"] = data["user"]["id"]
session["launch_id"] = data["launch_id"]
session["can_grade"] = data["grade_passback"]
session["storage"] = data["storage"]
session["api"] = data["api"]
provision(data) # see Just-in-time provisioning
return route_to_screen(data["location"])
Just-in-time provisioning
Voshi never tells your app about a school, course, or person ahead of time. There is no roster sync, no admin onboarding step, and no API that hands you a list of users to create. A launch is the first and only notice you get that any of them exist — and it arrives with the student already waiting.
So write your receiver as an upsert, not a lookup. Any of these can be new on any launch, in any combination:
| What may be new | Recognize it by | When it happens |
|---|---|---|
| A school | an unseen organization.id | Someone at a new institution launches your app for the first time. |
| A course | an unseen course.id | The first launch of any placement in that course. |
| A person | an unseen user.id | Someone who has never opened your app at this school. |
| An enrollment | an unseen user.id × course.id pair | A user you already know, launching from a course you haven't seen them in. |
| A placement | an unseen resource_link_id | An instructor placed another copy of a location you already support. |
user.id is stable per school. The same human at two institutions arrives as two different user.ids, and nothing in the launch lets you connect them — there's no email or name to match on. Treat users as belonging to their organization.
Provision in dependency order
Everything you need is in the token, so one pass over the claims creates the whole chain. Do it inside a single transaction, and make each write idempotent — the same launch data will arrive again on the user's next click.
- Python (psycopg)
- Node (pg)
from psycopg.types.json import Json
def provision(cur, data):
"""Upsert everything this launch tells us. Safe to run on every launch."""
org, course, user, loc = (
data["organization"], data["course"], data["user"], data["location"]
)
cur.execute("""
INSERT INTO organizations (id, title) VALUES (%s, %s)
ON CONFLICT (id) DO UPDATE SET title = EXCLUDED.title
""", (org["id"], org["title"]))
cur.execute("""
INSERT INTO courses (id, organization_id, name, label) VALUES (%s, %s, %s, %s)
ON CONFLICT (id) DO UPDATE
SET name = EXCLUDED.name, label = EXCLUDED.label
""", (course["id"], org["id"], course["name"], course["label"]))
# No PII arrives, so there is nothing on a user to keep up to date.
cur.execute("""
INSERT INTO users (id, organization_id) VALUES (%s, %s)
ON CONFLICT (id) DO NOTHING
""", (user["id"], org["id"]))
# Role belongs to the enrollment, not the user — see below.
cur.execute("""
INSERT INTO enrollments (user_id, course_id, role, last_seen_at)
VALUES (%s, %s, %s, now())
ON CONFLICT (user_id, course_id) DO UPDATE
SET role = EXCLUDED.role, last_seen_at = EXCLUDED.last_seen_at
""", (user["id"], course["id"], user["role"]))
cur.execute("""
INSERT INTO locations (id, type, label, params) VALUES (%s, %s, %s, %s)
ON CONFLICT (id) DO UPDATE
SET type = EXCLUDED.type, label = EXCLUDED.label, params = EXCLUDED.params
""", (loc["id"], loc["type"], loc["label"], Json(loc["params"])))
# resource_link_id is "" when the placement was never deep-linked.
if data["resource_link_id"]:
cur.execute("""
INSERT INTO placements (id, course_id, location_id) VALUES (%s, %s, %s)
ON CONFLICT (id) DO NOTHING
""", (data["resource_link_id"], course["id"], loc["id"]))
// Upsert everything this launch tells us. Safe to run on every launch.
async function provision(db, data) {
const { organization: org, course, user, location: loc } = data
await db.query(
`INSERT INTO organizations (id, title) VALUES ($1, $2)
ON CONFLICT (id) DO UPDATE SET title = EXCLUDED.title`,
[org.id, org.title],
)
await db.query(
`INSERT INTO courses (id, organization_id, name, label) VALUES ($1, $2, $3, $4)
ON CONFLICT (id) DO UPDATE
SET name = EXCLUDED.name, label = EXCLUDED.label`,
[course.id, org.id, course.name, course.label],
)
// No PII arrives, so there is nothing on a user to keep up to date.
await db.query(
`INSERT INTO users (id, organization_id) VALUES ($1, $2)
ON CONFLICT (id) DO NOTHING`,
[user.id, org.id],
)
// Role belongs to the enrollment, not the user — see below.
await db.query(
`INSERT INTO enrollments (user_id, course_id, role, last_seen_at)
VALUES ($1, $2, $3, now())
ON CONFLICT (user_id, course_id) DO UPDATE
SET role = EXCLUDED.role, last_seen_at = EXCLUDED.last_seen_at`,
[user.id, course.id, user.role],
)
await db.query(
`INSERT INTO locations (id, type, label, params) VALUES ($1, $2, $3, $4)
ON CONFLICT (id) DO UPDATE
SET type = EXCLUDED.type, label = EXCLUDED.label, params = EXCLUDED.params`,
[loc.id, loc.type, loc.label, loc.params],
)
// resource_link_id is "" when the placement was never deep-linked.
if (data.resource_link_id) {
await db.query(
`INSERT INTO placements (id, course_id, location_id) VALUES ($1, $2, $3)
ON CONFLICT (id) DO NOTHING`,
[data.resource_link_id, course.id, loc.id],
)
}
}
Use real upserts, not check-then-insert. A student who double-clicks the course link, or opens your app in two tabs, produces two simultaneous first launches. SELECT followed by INSERT races itself and you get either duplicate rows or a constraint violation that surfaces to the user as a broken launch. Put unique constraints on (user_id, course_id) and friends and let the database resolve the conflict.
Re-sync the fields that change
Creating on first launch isn't enough — some claims change after the record exists, and the launch is your only chance to notice:
course.nameandcourse.labelchange when the institution renames or re-terms the course.location.label,location.type, andlocation.paramschange when you edit the location in the dashboard. Those edits apply immediately to placements instructors have already made, so an app that only wrote them at creation time will act on stale configuration.user.rolechanges when someone's role in the course changes — a student promoted to TA, for example.
A find_or_create that skips the update goes quietly stale. Write these on every launch.
Roles belong to the enrollment
user.role describes this person in this course, on this launch — not the person. The same user.id is legitimately a student in one course and an instructor in another, and either can change between launches. Store the role on the enrollment (user.id × course.id), never on the user, and refresh it every time. Deciding what to show from a role you cached at signup is the most common way apps end up showing instructor tools to a student.
Nothing is ever torn down
Launches are the only event your app receives. You will never be told that a student dropped the course, that an instructor deleted a placement, or that a term ended — those things simply stop producing launches. Don't design anything that waits for a teardown signal.
If staleness matters to you — seat counts, active-course reporting, data retention — derive it from activity instead: stamp a last_seen_at on every launch (as the samples above do) and age records out on your own schedule.
Keep the launch fast
Provisioning is the usual reason a receiver gets slow, and the user is watching a blank page while it runs. The upserts above are cheap; keep it that way. Push welcome emails, analytics events, content generation, and calls to external services into a background job, and render as soon as the rows the first screen needs exist.
Using App Data storage? Then most of this is already done for you. The four storage rows in the storage claim are created on first launch, so the URLs always point at rows that exist — there is nothing to provision and no schema to keep in sync. Apps that store all their state there can skip straight from verifying the JWT to rendering.
Launches you might not expect
Plan for a few launch shapes beyond the happy path:
- Instructor launches. Instructors launch the same locations students do — check
user.rolebefore showing student-only or instructor-only UI. - Repeat launches. The same user launching the same placement again is normal (they clicked the course link again). Recognize them by
user.idand resume where they left off — provisioning on every launch keeps this on the same code path as their first one. grade_passback: falseon a graded location. A location of typeassessmentcan still launch without grade passback if the LMS didn't create a gradebook line item for the placement. Degrade gracefully — see Grade passback.
Errors on Voshi's side
If a launch can't be forwarded — for example, the app is still in draft status, the location ID in the placement no longer exists, or the callback URL fails validation — Voshi shows the user an error page with a report ID. Your endpoint is never called, so you don't need to handle these; they show up as instructor reports instead. The fix is almost always in the dashboard: activate the app or repair the location/callback configuration.