Skip to main content
Version: 2026-08-19 (archived)

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.

warning

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 named launch_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:

  1. Verify the JWT (signature + expiry).
  2. 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.
  3. Store what you'll need later: launch_id (required to send a grade), user.id, the storage row URLs, and the api token if you plan to use the App Data API.
  4. Provision the school, course, user, enrollment, and placement if you haven't seen them before — see Just-in-time provisioning.
  5. Switch on location.id (or its params) 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 newRecognize it byWhen it happens
A schoolan unseen organization.idSomeone at a new institution launches your app for the first time.
A coursean unseen course.idThe first launch of any placement in that course.
A personan unseen user.idSomeone who has never opened your app at this school.
An enrollmentan unseen user.id × course.id pairA user you already know, launching from a course you haven't seen them in.
A placementan unseen resource_link_idAn instructor placed another copy of a location you already support.
note

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.

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"]))
warning

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.name and course.label change when the institution renames or re-terms the course.
  • location.label, location.type, and location.params change 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.role changes 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.

note

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.role before 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.id and resume where they left off — provisioning on every launch keeps this on the same code path as their first one.
  • grade_passback: false on a graded location. A location of type assessment can 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.