Skip to main content
Version: 2026-09-21 (current)

Serving your locations

The locations endpoint is the third HTTPS endpoint you build — the Locations URL you give when registering your tool. When an instructor places your tool in a course, Voshi asks this endpoint what your tool currently offers and shows them the answer.

That makes your catalog live: you can add a chapter, retire a quiz, or tailor what a particular school sees without touching anything in Voshi. You choose how it is presented too — a flat list, or groups the instructor opens as needed — and you can send them a message alongside it, which is the only way to explain an empty picker.

note

A tool with no Locations URL cannot be placed in a course. The instructor gets a deep-link error instead of a picker, so this endpoint is required before your tool goes live.

The request Voshi sends you​

POST /voshi/locations HTTP/1.1
Host: myapp.example.com
Content-Type: application/x-www-form-urlencoded

launch_data=eyJhbGciOiJSUzI1NiIsImtpZCI6...

The same single launch_data field, from Voshi's server, signed the same way. Verify the signature exactly as you verify a launch — see Verifying the launch JWT. It's the only credential on the request, so an unverified launch_data is an unauthenticated caller asking for your catalog.

Claims​

{
"iat": 1751470000,
"exp": 1751477200,
"iss": "https://api.link.voshi.com",
"api": {
"domain": "api.link.voshi.com",
"token": null
},
"app": "I4ppXy",
"context": "IcT91mBxze",
"organization": {
"title": "Example University",
"issuer": "I1ssU3r",
"client": "Icl13nt",
"deployment": "Id3pL0yMnt"
},
"user": {
"id": "In4kDp7yZq",
"member": "Im8mBr42",
"groups": ["instructor"],
"given_name": "Ada",
"family_name": "Lovelace",
"full_name": "Ada Lovelace",
"email": "ada@example.edu"
}
}

app, context, organization, and user mean what they mean in a launch — use context and organization if you tailor the list per course or per school.

Because only course staff place content, user.email and the name fields are normally populated here (they follow the same course-staff-only rule as a launch). That makes user.email the practical claim to branch on if some instructors should see destinations others don't.

warning

api.token is null here, and there is no launch_id, location, course, resource_link, storage, or provision claim: this request isn't a launch, so there's nothing to read or write and no per-user credential. Every user field can also be null depending on how the instructor reached the picker — treat the whole object as optional and default to your full catalog when it's empty.

note

Requests from the dashboard's Sandbox are ordinary requests — the trial course places content through the same picker an instructor uses, so your endpoint is called exactly as it will be in production and there is no test flag to branch on. Answer normally; that's the point of the exercise.

The response​

Answer 200 with a JSON object. It has three keys, all optional: locations (what you offer), grouped_locations (the same, arranged into collapsible sections), and message (a sentence for the instructor to read). The simplest useful answer is locations alone:

{
"locations": {
"chapter3_quiz": {
"type": "assessment",
"label": "Chapter 3 Quiz",
"description": "20 questions on normal distributions. Auto-graded.",
"points": 50
},
"chapter3_practice": {
"type": "practice",
"label": "Chapter 3 Practice",
"description": "Untimed drill on the same material."
},
"syllabus": {
"type": "content",
"label": "Course Syllabus"
}
}
}

Anything else at the top level is ignored. Return the full current list on every call — Voshi asks each time an instructor opens the picker — and in the order you want it read, since nothing is re-sorted on the way to the instructor.

An answer naming none of locations, grouped_locations, or message is rejected as an invalid structure: it means we reached something other than your locations endpoint. To say "nothing right now", send a message.

What you serve decides what instructors can place now. When one of them places a destination, Voshi stores a copy of that entry and every launch of that placement carries the copy — see the warning on Locations.

Per-location fields​

typestringrequired

What the location is for: assessment, practice, content, or setup. Only assessment can pass a grade back. See Locations.

labelstringrequired

The name the instructor sees in the picker, and the default name of the placement in their course.

descriptionstring

A longer explanation shown next to the label while they choose. Optional.

pointsnumberdefault: 100

For assessment locations, the points the gradebook column is worth. Ignored for other types — and dropped if a location stops calling itself an assessment. Your grade submissions are fractions from 0 to 1, which the LMS scales to this number.

message — explaining what they are looking at​

Send message to put a line of text above the picker. It exists mainly for the answer with nothing in it: an empty picker tells the instructor only that something is wrong, while you are the one who knows why — their email matches no account, their institution has adopted nothing, their trial expired.

{
"message": "We couldn't match your LMS email address to an account, so there's nothing here to place yet. Ask your administrator to add it, then reopen this picker.",
"locations": {}
}
messagestring

Text shown to the instructor above the picker. Optional, and valid on its own — an answer with a message and no locations is a complete answer.

Two things to know about how it is treated:

  • It is displayed as plain text. Any markup is stripped before it is shown, so a link, a bold word, or an <a> tag is pointless — write the sentence you want them to read. Line breaks survive.
  • It is truncated at 2000 characters. This is a sentence or two, not a page.
note

A message is shown whether or not you also send locations, so you can use it for a heads-up beside a full catalog ("only your pilot units are listed this term"). Don't send one on every ordinary call — a note that is always there is one nobody reads.

grouped_locations — arranging a large catalog​

If you offer more than a screenful, group it with grouped_locations. Each group is a collapsible section: the instructor sees the labels first, opens what interests them, and ticks individual locations or takes a whole group at once — from as many groups as they like in a single placement.

{
"locations": {
"syllabus": {
"type": "content",
"label": "Course Syllabus"
}
},
"grouped_locations": [
{
"label": "Unit 1 — Descriptive Statistics",
"description": "Chapters 1-3",
"locations": {
"u1_quiz": {
"type": "assessment",
"label": "Unit 1 Quiz",
"points": 50
}
},
"grouped_locations": [
{
"label": "Extra credit",
"locations": {
"u1_bonus": {
"type": "practice",
"label": "Bonus drill"
}
}
}
]
},
{
"label": "Unit 2 — Probability",
"locations": {
"u2_quiz": {
"type": "assessment",
"label": "Unit 2 Quiz",
"points": 50
}
}
}
]
}

Every group starts collapsed, at every level, and shows a count of what the instructor has ticked inside it while it is shut. Its checkbox takes or releases the whole subtree — the group's own locations and all of its subgroups' — so a unit can be placed in one click.

Top-level locations are shown above the groups and are not collapsed, so put there the handful of things everyone places — a location inside a collapsed group is a location fewer instructors click. A group with nothing in it still renders, as a row that opens onto nothing, so drop your empty groups rather than sending them.

Group fields​

labelstringrequired

The group's name. Until the instructor opens it, this is the only thing they see of what is inside — so make it say what is in there. Treated like message: reduced to plain text, and truncated at 200 characters.

It must still say something once that is done. An empty label, or one that is nothing but whitespace or markup, is refused as an invalid structure rather than drawn as a blank row nobody can identify.

descriptionstring

A line under the label, shown whether or not the group is open. Optional, and treated the same way — plain text, truncated at 200 characters.

locationsobject

The locations in this group, keyed by external ID exactly as the top-level locations object is. Same required fields, same extid rules.

grouped_locationsarray

Subgroups, the same shape all the way down — a group holds locations exactly the way the response itself does, which is why it is the same key. Nesting is capped at 5 levels; a deeper answer is refused as an invalid structure.

warning

Grouping is presentation only. A location's group is not part of its external ID, is not stored on the location, and never appears in a launch. That is what makes regrouping safe — you can reorganize your catalog freely, and content instructors have already placed keeps working. It also means the group cannot be used to distinguish two locations: two entries with the same extid in different groups are one location, shown twice.

Ordering​

The order you send is the order they see. Nothing is sorted, alphabetized, or rearranged — the picker draws your answer in the order your JSON puts it, so the sequence is a presentation decision you make, not one Voshi makes for you. Send your units in teaching order and they read in teaching order.

Three rules cover the whole response:

  • Groups appear in array order. grouped_locations is a list, and it is walked front to back, at every level of nesting.
  • Locations appear in the order the keys are written in their locations object — the first key is the first card. This holds for the top-level object and for each group's.
  • Locations come before groups, at the top level and inside every group: a group's own locations are drawn first, then its subgroups. So a group that mixes the two always shows its own items above its nested sections.
warning

Don't use purely numeric extids if order matters. Browsers reorder JSON object keys that are entirely digits — they move ahead of every other key and sort ascending, whatever order you wrote them in. {"10": …, "2": …} reaches the instructor as 2 then 10, and {"20": …, "syllabus": …} shows 20 first. That's a rule of the language, not something Voshi applies, so it happens before we could preserve anything.

Adding any non-digit character is enough to opt out — u2_quiz, quiz-2, q2. Prefixing your identifiers is worth doing anyway, for all the other reasons a bare number makes a poor extid.

Reordering is always safe to do later: a location's position is presentation only, exactly like its group, and placements instructors have already made are unaffected.

External IDs are yours, and permanent​

The key of each entry is the external ID (extid) — your own identifier for that destination. Use letters, digits, underscores, and dashes.

The first time Voshi sees an extid it creates its own location record for it, and from then on the two are bound: every link to that destination, in every course, at every school, points at that pairing. Both IDs arrive in the launch, as location.extid and location.id.

Serve the same extid for the same resource everywhere — every course, every school, every semester. That consistency is what makes a course copied into next term keep working. Don't put a context id, semester code, or instructor id in an extid. See What makes a good extid.

warning

Never reuse an extid for different content. Reassigning quiz1 from Chapter 1's quiz to Chapter 2's silently retargets every placement instructors have already made — students clicking last term's link get the new content, and you won't get an error. Retire an extid by dropping it from the list and issuing a new one.

Dropping an extid from your response only hides it from the picker — links that already exist keep launching indefinitely. The safe default is to keep serving every extid you have ever served. If you must stop offering one, keep handling its launches and render a "this is no longer available" screen of your own rather than erroring.

Examples​

@app.post("/voshi/locations")
def voshi_locations():
claims = verify_launch_jwt(request.form["launch_data"]) # see Verifying the JWT

email = (claims.get("user") or {}).get("email")
instructor = Instructor.query.filter_by(email=email).first() if email else None
if email and instructor is None:
# Nothing to offer, and we're the only ones who know why.
return {"message": "We couldn't match your LMS email address to an account. "
"Ask your administrator to add it, then reopen this picker."}

grouped = []
for unit in Unit.query.all():
grouped.append({
"label": f"Unit {unit.number} — {unit.title}",
"locations": {
f"u{unit.number}_quiz": {
"type": "assessment",
"label": f"Unit {unit.number} Quiz",
"description": unit.quiz_blurb,
"points": unit.quiz_points,
},
},
})

return {
"locations": {"syllabus": {"type": "content", "label": "Course Syllabus"}},
"grouped_locations": grouped,
}

When this endpoint fails​

An instructor is waiting on your response, so Voshi gives up after 30 seconds. These all end the same way — the instructor sees a deep-link error and places nothing:

What happenedWhat the instructor sees
No response within 30sDeep linking error: app connection timeout.
Connection refused, DNS failure, bad TLSDeep linking error: app connection failure.
Any non-2xx statusDeep linking error: app failed to provide the available locations (status=…)
A 3xx redirect (Voshi does not follow it)Deep linking error: the app redirected the locations request; locations_url must name the endpoint itself.
Body isn't JSONDeep linking error: the app returned invalid JSON.
JSON doesn't match the shape aboveDeep linking error: the app returned an invalid locations structure: …
Groups nested more than 5 deepDeep linking error: the app nested location groups more than 5 levels deep.
A group whose label is empty, or is only whitespace or markupDeep linking error: the app returned a location group with an empty label.
No Locations URL registeredDeep linking error: App … does not have a locations_url configured.

Your tool is never told that any of this happened — there's no retry callback and nothing in your logs but the request you failed to answer. Because these reach instructors rather than you, keep the handler cheap and independent of anything slow.

note

Placing a location also starts provisioning for it: Voshi calls your Provision URL for the course and the location as soon as the instructor confirms, without waiting for the first student.