Skip to main content

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.

caution

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 launchDeep linking
POST /api/launch/core/formPOST /api/launch/deeplinking/form
Send resourceOmit resource — nothing has been picked yet
LTIAAS redirects to your Launch URLLTIAAS redirects to your Deep Linking URL
POST /api/idtoken/core/formPOST /api/idtoken/deeplinking/form — again, no resource
Ends with the user in the toolEnds 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.

tip

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"
}
]
}
}
caution

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.

note

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:

  1. Message the parent window, as above, and let your page close the iframe and refresh the activity list.
  2. 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

All trademarks, logos, and service marks displayed on this website are the property of their respective owners. LTIAAS is a trademark of GatherAct, LLC, doing business as LTIAAS. Learning Tools Interoperability (LTI)® and LTI® are trademarks of 1EdTech Consortium, Inc. LTIAAS is not affiliated with, endorsed or sponsored by 1EdTech Consortium, Inc. or by any other owners of third-party trademarks used on this website. LTIAAS is not responsible for the content, quality, or accuracy of any websites linked to or from this website that are not owned by LTIAAS. If you have any questions or concerns about the use of any trademarks or content on this website, please contact us.