Deep Linking
A core launch opens content a teacher has already chosen. Deep linking is how they choose it: the tool renders its own content picker inside your LMS, the teacher selects something, and the tool hands the selection back for you to store.
The flow is the core launch with three changes and one extra step at the end.
Deep linking must be enabled on your account in the portal. If it is not, the launch endpoints return 403 INACTIVE_SERVICE.
What Changes
| Core launch | Deep linking |
|---|---|
POST /api/launch/core/form | POST /api/launch/deeplinking/form |
Send resource | Omit resource — nothing has been picked yet |
| LTIAAS redirects to your Launch URL | LTIAAS redirects to your Deep Linking URL |
POST /api/idtoken/core/form | POST /api/idtoken/deeplinking/form — again, no resource |
| Ends with the user in the tool | Ends with a DEEP_LINKING_RESPONSE at your Service URL |
Everything else — verifying the payload, identifying the user, sending roles and context — is identical.
Step 1 — Open the Picker
Typically triggered by a teacher clicking "Add content" while editing a course.
app.post('/api/lti/deeplink', requireLogin, async (req, res) => {
const { courseId, toolClientId } = req.body
await assertUserIsTeacherOf(req.user, courseId)
const { data } = await axios.post(
`${LTIAAS_URL}/api/launch/deeplinking/form`,
{
clientId: toolClientId,
context: courseId,
user: req.user.id
// no `resource` — that is what the teacher is about to choose
},
{ headers: { Authorization: `Bearer ${LTIAAS_API_KEY}` } }
)
res.json({ form: data.form })
})
Render the form in an iframe exactly as in a core launch.
Step 2 — Answer at Your Deep Linking URL
LTIAAS redirects to your Deep Linking URL with a payload to verify. Same code as a core launch, minus the resource lookup:
app.get('/lti-deep-linking', async (req, res) => {
let decoded
try {
decoded = jwt.verify(req.query.payload, LTIAAS_PUBLIC_KEY)
} catch (err) {
return res.status(401).send('Invalid launch token')
}
const user = await db.users.find(decoded.parameters.user)
const course = await db.courses.find(decoded.parameters.context)
const { data } = await axios.post(
`${LTIAAS_URL}/api/idtoken/deeplinking/form`,
{
metadata: decoded.metadata,
user: {
id: user.id,
name: user.name,
email: user.email,
roles: ['CONTEXT_INSTRUCTOR']
},
context: {
id: course.id,
label: course.code,
title: course.name,
type: ['CourseOffering']
}
// still no `resource`
},
{ headers: { Authorization: `Bearer ${LTIAAS_API_KEY}` } }
)
res.send(data.form)
})
The teacher now sees the tool's content picker.
The payload's type claim is DEEP_LINKING_LOGIN here rather than CORE_LOGIN. If you serve both flows from one route, branch on it.
Step 3 — Receive the Selection
The teacher picks something. The tool posts its selection to LTIAAS, which validates it and forwards it to your Service URL as a DEEP_LINKING_RESPONSE service request.
app.post('/lti-services', async (req, res) => {
const decoded = jwt.verify(req.body.payload, LTIAAS_PUBLIC_KEY)
if (decoded.type === 'DEEP_LINKING_RESPONSE') {
const { context, clientId, contentItems } = decoded.parameters
const course = await db.courses.find(context)
const tool = await db.tools.findByClientId(clientId)
// Store each selection as an activity in the course. The item's `url`
// becomes the launchEndpoint for future core launches of this activity.
for (const item of contentItems) {
await db.activities.create({
courseId: course.id,
toolClientId: clientId,
name: item.title || tool.name,
launchEndpoint: item.url
})
}
// The teacher's browser is sitting on this response, so close the picker.
return res.send(`<html><body><script>
window.parent.postMessage({ type: "deep-linking-complete" }, "*");
</script></body></html>`)
}
// ... other service request types
})
The payload:
{
"type": "DEEP_LINKING_RESPONSE",
"parameters": {
"context": "2022CSEa5e6c431b91",
"clientId": "qR8E0iHqSdR30DdfQAbcaBGjKT65",
"contentItems": [
{
"type": "ltiResourceLink",
"url": "https://mytool.com/lti/launch?resource=13",
"title": "Resource 13"
}
]
}
}
This request arrives form-urlencoded, not as JSON — it is delivered by a self-submitting form in the teacher's browser rather than a server-to-server call. Every other service request uses a JSON body. Make sure your route parses both, or req.body.payload will be undefined.
contentItems only ever contains ltiResourceLink items. LTIAAS rejects other content types before they reach you.
Storing the Item
The important field is url. Save it as the activity's launch endpoint and pass it as launchEndpoint on future core launches — that is what makes the launch open this piece of content rather than the tool's front page.
await axios.post(`${LTIAAS_URL}/api/launch/core/form`, {
clientId: activity.toolClientId,
context: courseId,
resource: activity.id,
user: req.user.id,
launchEndpoint: activity.launchEndpoint // the deep-linked URL
}, { headers: { Authorization: `Bearer ${LTIAAS_API_KEY}` } })
Closing the Picker
Because the teacher's browser is on your response, you decide what happens next. Two patterns work well:
- Message the parent window, as above, and let your page close the iframe and refresh the activity list.
- Return a confirmation page listing what was added, with your own "Done" button — useful when you want the teacher to rename or configure the item before it is saved.
Next Steps
- Handling service requests — the same Service URL also receives roster and grade traffic.
- Core launch — how the content you just stored gets opened.
