Skip to content

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_links permissions.
  • read_learners on the same API key if your backend needs to look up learner IDs through GET /api/v1/learners.
  • Existing, active McCoy learner accounts.
  • The learner's public McCoy learner_id from GET /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

  1. Your portal authenticates the learner.
  2. Your backend maps that learner to a McCoy learner_id.
  3. Your backend creates a Surface embed link.
  4. Your frontend sets the iframe src to the returned embed_url.
  5. 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.

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>
function loadMcCoySurface(embedUrl) {
  document.getElementById("mccoy-surface").src = embedUrl;
}

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.

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:

const returnPath = sessionStorage.getItem("mccoy_surface_return_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 postMessage events by checking event.origin.