Surface Embedding
Use Surface embedding when your portal loads McCoy Surface in an iframe and your backend signs learners in without sending them through a separate McCoy login page.
Keep your API key server-side
Create Surface embed links from a private backend or server-side function only; do not call POST /api/v1/surface/embed-links from browser JavaScript or expose your McCoy API key to frontend code.
Before You Start
You need:
- Surface embedding enabled for your organization. If it is not enabled yet, contact McCoy.
- Allowed portal origins configured in Terminal under Learner Portal settings.
- A tenant-scoped API key with
create_surface_embed_linkspermissions. read_learnerson the same API key if your backend needs to look up learner IDs throughGET /api/v1/learners.- Existing, active McCoy learner accounts.
- The learner's public McCoy
learner_idfromGET /api/v1/learners.
For learners who already exist in McCoy, start with Learner Directory to find each learner_id.
Allowed portal origins must be exact origins, not full page URLs. Include the scheme and host, and include a port only when needed.
| Valid | Not valid |
|---|---|
https://portal.acme.com |
https://portal.acme.com/dashboard |
https://learn.acme.edu |
portal.acme.com |
https://portal.staging.acme.com |
https://*.acme.com |
Use only HTTPS origins you control for production tenants. For integration testing, use a dedicated non-production tenant and remove development-only origins before production launch.
Integration Flow
- Your portal authenticates the learner.
- Your backend maps that learner to a McCoy
learner_id. - Your backend creates a Surface embed link.
- Your frontend sets the iframe
srcto the returnedembed_url. - Surface opens inside the iframe and signs in the learner.
Find The Learner ID
If your system does not already store the learner's McCoy learner_id, look it up with the learner directory:
curl "https://api.mccoy.org/api/v1/learners?email=learner@example.com" \
-H "X-Api-Key: YOUR_API_KEY"
learner_id is stable for the learner within the tenant. Store it in your system after the first lookup so you do not need to call the learner directory every time the learner opens Surface.
See Learner Directory for the full response shape and filtering options.
Create An Embed Link
Call this McCoy endpoint from your backend after you have identified the signed-in learner:
curl -X POST "https://api.mccoy.org/api/v1/surface/embed-links" \
-H "X-Api-Key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"learner_id": "lrn_01HX7N8V2K9Q4R6M3B5C7D9E0F",
"return_path": "/courses"
}'
Response:
{
"data": {
"embed_url": "https://acme.surface.mccoy.org/embedded-sign-in#embed_code=ONE_TIME_CODE",
"expires_at": "2026-05-12T14:32:00.000Z"
}
}
Field notes:
| Field | Description |
|---|---|
learner_id |
The public McCoy learner ID returned by GET /api/v1/learners. |
return_path |
Optional Surface path to open after sign-in. Defaults to /. Must be a root-relative path that starts with /, does not start with //, and does not include a URL fragment. Query strings are allowed. |
Each embed URL contains a one-time code that expires after 2 minutes. Create a fresh embed link whenever the learner opens or refreshes the portal page.
Load The Iframe
Your frontend should use the embed_url returned by your backend directly:
<iframe
id="mccoy-surface"
title="McCoy Surface"
style="width: 100%; height: 100%; border: 0;"
></iframe>
Do not parse, store, or reuse the one-time code in the URL. If the parent page refreshes, your backend should request a new embed link.
Preserve Deep Links On Refresh
Surface can notify the parent page when the learner navigates. Store only the latest Surface path, then send it as return_path when your backend creates the next embed link.
mccoy.surface.route is the fixed event type Surface sends when the embedded learner route changes. Pass the current embed_url into the listener setup:
function saveMcCoySurfaceReturnPath(embedUrl) {
const surfaceOrigin = new URL(embedUrl).origin;
window.addEventListener("message", (event) => {
if (event.origin !== surfaceOrigin) return;
const message = event.data;
if (message?.type !== "mccoy.surface.route") return;
const path = message.path;
if (
typeof path !== "string" ||
!path.startsWith("/") ||
path.startsWith("//") ||
path.includes("#")
) {
return;
}
sessionStorage.setItem("mccoy_surface_return_path", path);
});
}
saveMcCoySurfaceReturnPath(embedUrl);
On the next page load, your frontend can ask your backend for a fresh embed link using that stored path:
Troubleshooting
| Symptom | What to check |
|---|---|
The API returns 400 |
Confirm the request body is JSON, learner_id is a McCoy learner ID, and return_path is root-relative with no fragment. |
The API returns 403 |
Confirm Surface embedding is enabled and the API key has create_surface_embed_links. Also confirm the learner is active. |
| The iframe is blocked by the browser | Confirm the parent portal origin is listed in Terminal as an allowed portal origin. |
The API returns 404 for a learner |
Confirm the learner_id belongs to a learner in this API key's tenant. |
| The iframe shows that the session could not be opened | Request a fresh embed URL. Embed URLs expire quickly and can only be used once. |
Security Checklist
- Keep the API key on your backend.
- Add only portal origins you control in Terminal.
- Request a fresh embed URL for every iframe load.
- Store only Surface paths for refresh handling, not embed URLs or one-time codes.
- Validate
postMessageevents by checkingevent.origin.