Skip to content

Developers

Build apps on Wislab

Build an app that lives outside Wislab and works inside an academy through the API, webhooks and its admin. Test it for free in your own academy and publish it when it is ready.

What an external app is

An external app runs outside Wislab: it has its own repository, its own database and its own deploy. Wislab never runs its code.

It works inside an academy in three ways: the API v1, with the installation token the academy gives it; the signed webhooks Wislab sends it; and an iframe inside the academy's admin, at /admin/apps/<handle>.

If the academy wants, it can also show up on the academy's public site, with sanitized blocks or isolated full pages.

Kinds of external apps
KindWhat it is
Wislab's (first_party)The apps Wislab builds and registers.
An academy's own (custom)An academy's own app. Its owner or an admin registers it in Admin > Apps > Develop apps.
CatalogA custom app Wislab reviewed and published: it shows up in Admin > Apps of every academy.

Build for free in your own academy

Any academy owner or admin can register apps and test their own app in their own academy, on any package, including the free one.

In your academy you can

  • Install your app.
  • Open it inside the admin.
  • Sign in to it from outside, with your academy.
  • Call the API v1 with its installation token. While you build, the rate limit is lower: 30 requests per minute per installation instead of 120.
  • Receive its webhooks and send a test event.

You need a package with external apps to

  • Install apps from other developers: Wislab's, the catalog's or one from an install link.
  • Ask Wislab for a review, so other academies can install your app.
  • Let your app show anything on the academy's public site: blocks or full pages.

Apps do not change what an academy sells

An app can never make an academy able to sell if its package does not sell.
Response 429
HTTP/1.1 429 Too Many Requests
Retry-After: 18

{ "error": "RATE_LIMITED", "message": "Rate limit exceeded." }

Register the app

Register your app in Admin > Apps > Develop apps (/admin/apps/develop). The academy's owner or an admin can do it.

The form asks for these fields:

Fields of an app registration
FieldWhat it is
handleThe app's id in URLs. Lowercase letters, numbers and hyphens, 2 to 41 characters, starting with a letter or a number. It is the aud of every session token and never changes. It cannot be install, develop, new or launch, or the id of a Wislab app.
nameThe name the academy sees, up to 80 characters. Neither the name nor the handle may pass for another brand.
descriptionWhat the app does, up to 500 characters. If you ask for personal data, say what you use it for.
appUrlThe page the admin loads in the iframe. Always public https. http://localhost only works on a local Wislab, never in production. It cannot be a Wislab domain or an academy's domain.
allowedOriginsExtra origins allowed to send messages to the admin, scheme and host only: https://app.example.com. Always https in production.
scopesThe API permissions the app will use. The academy sees them before installing; the ones that expose personal data are marked.
webhookUrl, webhookEventsWhere to receive events and which ones. Optional. The URL must be public https, never localhost: use a tunnel while you build. Each event needs its scope.
storefrontBlocks, proxyPath, storefrontOriginsWhat the app may show on the public site. The Storefront section explains it.

When you register it, you see the app secret and the install link https://wislab.app/apps/install/<handle>?key=… once. Keep the secret on your server only.

You can regenerate the secret and the link from your app's page. A new secret leaves the app without sessions until you use it; a new link invalidates the old one, but installations stay.

Environment variables
# Shown once when you register the app (Admin > Apps > Develop apps)
WISLAB_APP_HANDLE=my-app
WISLAB_APP_SECRET=

# Per academy, shown once at install. Store one pair per academy.
WISLAB_INSTALLATION_TOKEN=wsk_app_...
WISLAB_WEBHOOK_SECRET=whsec_...

WISLAB_API_URL=https://wislab.app/api/v1

The embedded admin session

The admin loads your appUrl in an iframe. When your page posts wislab:ready, the admin answers with wislab:session and a session token.

The admin only accepts messages from your app's iframe and from the origins the academy accepted when it installed the app.

Messages between your app and the admin
MessageDirectionWhat it does
wislab:readyApp to adminSays your page is ready. The admin answers with the session.
wislab:getSessionTokenApp to adminAsks for a new token when yours expired.
wislab:resize { height }App to adminYour page height in pixels, so the iframe fits it (between 320 and 6000).
wislab:toast { level, message }App to adminShows a notice in the admin, prefixed with your app's name. level is success or error; the message is cut at 200 characters.
wislab:session { token, tenant, handle }Admin to appThe session token, the academy slug and your handle.

The session token

It is an HS256 JWT signed with your app secret, valid for 60 seconds. It carries these claims:

Session token claims
ClaimValue
issThe academy slug.
audYour handle.
subThe person's id in that academy.
roleTheir role in the academy: owner, admin…
tenantId, tenantSlugThe academy.
name, emailThe person, when the academy has them. Absent otherwise.

Always verify on your server

Verify the token on your server with the app secret, on every exchange. Never trust the claims decoded in the browser.

Sign in with the academy, outside the iframe

  1. If your app also opens outside the admin, its sign-in screen links to https://<academy>/admin/apps/launch/<handle>.
  2. Wislab asks for an owner or admin session (without one, the person signs in first and comes back) and checks the same things as opening the app in the admin.
  3. Then it sends the browser to your appUrl with the token in the fragment: #wislab_session=<jwt>. The fragment never reaches a server, a log or a Referer.
  4. Trade it for your own session with the same exchange you use inside the iframe.
Server
// app/api/wislab/session/route.js
// Trades the 60-second session token for your own session.
import jwt from "jsonwebtoken";

export async function POST(req) {
  const { token } = await req.json();
  let claims;
  try {
    claims = jwt.verify(token, process.env.WISLAB_APP_SECRET, {
      algorithms: ["HS256"],
      audience: process.env.WISLAB_APP_HANDLE,
    });
  } catch {
    return Response.json({ error: "UNAUTHORIZED" }, { status: 401 });
  }
  if (claims.iss !== claims.tenantSlug) {
    return Response.json({ error: "UNAUTHORIZED" }, { status: 401 });
  }
  // claims.sub is the person; claims.role, claims.tenantId, claims.tenantSlug.
  // Start your own session here, bound to claims.tenantSlug.
  return Response.json({ data: { userId: claims.sub, role: claims.role } });
}
Browser
// The embedded page. The admin frames it with referrerpolicy=origin.
const admin = new URL(document.referrer).origin;

window.addEventListener("message", async (event) => {
  if (event.source !== window.parent || event.origin !== admin) return;
  if (event.data?.type !== "wislab:session") return;
  await fetch("/api/wislab/session", {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({ token: event.data.token }),
  });
});

window.parent.postMessage({ type: "wislab:ready" }, admin);
window.parent.postMessage({ type: "wislab:resize", height: document.body.scrollHeight }, admin);
window.parent.postMessage({ type: "wislab:toast", level: "success", message: "Saved" }, admin);
Sign in with the academy, outside the iframe
// Your appUrl, opened from /admin/apps/launch/<handle>
const token = new URLSearchParams(location.hash.slice(1)).get("wislab_session");
if (token) {
  // Drop the token from the address bar, then trade it on your server.
  history.replaceState(null, "", location.pathname + location.search);
  fetch("/api/wislab/session", {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({ token }),
  });
}

Let only the academy frame you

To the browser, every academy on *.wislab.app is the same site. A cookie your app sets inside one academy's admin is also sent when another academy frames your app. With a wildcard, that other academy could show your app with the first academy's session and trick its admin into clicking (clickjacking).

  1. The admin loads your appUrl with ?wislab_academy=<academy origin>, for example https://youracademy.wislab.app or the academy's own domain.
  2. If that origin is an academy where your app is installed, answer Content-Security-Policy: frame-ancestors <that exact origin>. Otherwise, frame-ancestors 'none'. Each academy's origin is the url that GET /api/v1/academy returns with its token.
  3. Never use X-Frame-Options or the https://*.wislab.app wildcard.
  4. Keep the embedded session per page load: in memory, renewed with wislab:getSessionToken. If you need a cookie, bind it to the academy: name it per academy or store the token's iss, and refuse it when it does not match the page's wislab_academy.

The review checks it: it loads your page with an academy's origin and fails when there is an X-Frame-Options, when frame-ancestors is missing or when it uses a wildcard.

Server
// The page the admin embeds (your appUrl)
export async function GET(req) {
  const academy = new URL(req.url).searchParams.get("wislab_academy");
  // Exact match against the academy origins you stored at install
  // (the `url` of GET /api/v1/academy for each installation token).
  const allowed = academy !== null && (await installedAcademyOrigins()).has(academy);
  return new Response(await renderEmbeddedPage(), {
    headers: {
      "content-type": "text/html; charset=utf-8",
      "content-security-policy": `frame-ancestors ${allowed ? academy : "'none'"}`,
    },
  });
}

The installation token

  • When an academy installs your app, it gets a wsk_app_… token, shown once. There is one per academy, and the API knows which academy it belongs to. Store it per academy, for example by its slug.
  • Send it from your server in the Authorization: Bearer header.
  • It only works for the scopes the academy granted at install. A scope it lacks answers 403.
  • The rate limit is per installation: 120 requests per minute, or 30 while you build in your own academy.
  • It stops working after 90 days without use. Installing or rotating the token restarts the clock. The academy rotates it in Admin > Apps and the old one stops working at once.
  • If Wislab suspends the app, all its tokens answer 403.
  • GET /api/v1/academy needs no scope: it returns the token's academy (id, name, slug, domain, URL, locales, currency, package, logo and color). Never people.

Every endpoint, scope, error and limit is in the API v1 reference.

Request
curl https://wislab.app/api/v1/academy \
  -H "Authorization: Bearer $WISLAB_INSTALLATION_TOKEN"
Response 403
{
  "error": "FORBIDDEN",
  "message": "Installation lacks required scope: payments:read."
}

Webhooks

Wislab sends a POST with a JSON { id, type, apiVersion, createdAt, data }. Today apiVersion is v1.

It carries three headers: webhook-id, webhook-timestamp (Unix seconds) and webhook-signature. The signature is v1,<base64 HMAC-SHA256> over {id}.{timestamp}.{body}, keyed with the base64-decoded secret without its whsec_ prefix. It is the Svix format, so its libraries verify it too.

Each academy has its own whsec_… secret. It gets it once at install, together with the token: store it per academy.

How to receive them

  • Verify the signature over the raw body, before parsing it: re-serializing the JSON changes the bytes.
  • Reject timestamps more than 5 minutes away from your clock.
  • Dedupe by id: the same event can arrive twice.
  • Answer 2xx fast (you have 10 seconds) and do the work afterwards. A redirect counts as a failure.
  • When you do not answer 2xx, Wislab retries after 1 min, 5 min, 30 min, 2 h, 8 h and 24 h, with a 10 % margin: 7 attempts in total. Then it drops the delivery.
  • If Wislab suspends the app, deliveries stop and what was queued is dropped: nothing piled up arrives when it is reactivated.

Events and scopes

Each event needs the scope that would give you the same data through the API. Registration refuses an event without its scope, and each academy's endpoint only receives what the scopes it granted cover.

Scope each event needs
EventsScope
user.created, user.deactivatedusers:read
lead.createdcrm:read
payment.succeededpayments:read
enrollment.createdenrollments:read
course.completed, exam.passedcompletions:read
certification.issued, certification.verified, certification.revokedcertificates:read
Server
import { createHmac, timingSafeEqual } from "node:crypto";

// Throws unless the delivery is signed with this secret and is recent.
export function verifyWislabWebhook(secret, body, headers) {
  const id = headers.get("webhook-id");
  const timestamp = Number(headers.get("webhook-timestamp"));
  const signature = headers.get("webhook-signature") ?? "";
  if (!id || !Number.isInteger(timestamp)) throw new Error("missing headers");
  if (Math.abs(Date.now() / 1000 - timestamp) > 300) throw new Error("stale");

  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
  const expected = createHmac("sha256", key).update(`${id}.${timestamp}.${body}`).digest();
  // Several signatures may travel space-separated; any valid one is enough.
  const valid = signature.split(" ").some((entry) => {
    const [version, value] = entry.split(",");
    if (version !== "v1" || !value) return false;
    const given = Buffer.from(value, "base64");
    return given.length === expected.length && timingSafeEqual(given, expected);
  });
  if (!valid) throw new Error("bad signature");

  const event = JSON.parse(body);
  if (event.id !== id) throw new Error("id mismatch");
  return event;
}

// app/api/wislab/webhooks/route.js
export async function POST(req) {
  const body = await req.text(); // the raw body, before any JSON.parse
  let event;
  try {
    // The webhook secret of the academy (one per installation).
    event = verifyWislabWebhook(process.env.WISLAB_WEBHOOK_SECRET, body, req.headers);
  } catch {
    return Response.json({ error: "INVALID_SIGNATURE" }, { status: 401 });
  }
  // Dedupe by event.id, queue the work and answer right away.
  return Response.json({ received: true });
}

Lifecycle events

Every app with a webhook URL receives these events without asking for a scope or subscribing: Wislab creates one endpoint per installation whenever the app declares a webhook URL. They carry no personal data.

They are signed with that installation's webhook secret, like every other event.

An app's lifecycle events
EventWhen it arrives
app.installedRight after install. It is signed with the new secret the academy hands you with the token: until you store it, answer anything but 2xx and the retries deliver it.
app.uninstalledThe academy uninstalled the app. It is the last delivery to that endpoint: it is retried like the others and the endpoint is removed once it is delivered or the attempts run out. Stop calling the API and delete what you keep for that academy.
app.scopes_changedThe academy reinstalled the app from the consent screen and granted different scopes than it had. It also carries previousScopes.
app.testYou send it from Develop apps with "Send test event". It carries { app, academy, message, occurredAt }, no scopes.

What data carries

  • app: { handle }.
  • academy: { id, slug, name }.
  • scopes: the scopes granted to the installation after the change.
  • occurredAt: when it happened, in ISO 8601.
Example: app.uninstalled
{
  "id": "evt_8f2c41d09a7b3e6512c4d8f0",
  "type": "app.uninstalled",
  "apiVersion": "v1",
  "createdAt": "2026-11-03T18:20:00.000Z",
  "data": {
    "app": {
      "handle": "my-app"
    },
    "academy": {
      "id": "cmh2x9k0a0001lc04ab12cd34",
      "slug": "my-academy",
      "name": "My Academy"
    },
    "scopes": [
      "courses:read",
      "enrollments:read"
    ],
    "occurredAt": "2026-11-03T18:20:00.000Z"
  }
}

Storefront: blocks and pages

An installed app can show up on the academy's public site, under two rules that never change: your code never runs inside the academy's page, and the academy decides where anything of yours appears, from Design, like any other section of its theme.

Only themes that accept app blocks offer them. The academy needs a package with external apps, and its visitors only see your content once Wislab approved the app.

Html blocks

Wislab sends a server-to-server GET to <your origin><path>?tenant=&instance=&settings=, with the signed headers. You have 2 seconds and 256 KB. The HTML is sanitized with a closed list: structural and text tags, class as the only styling hook and http(s) URLs; no script, style, iframe, form, inline styles or event handlers. The result is cached for 5 minutes. When you do not answer in time, the block shows nothing that time.

Iframe blocks

The block is an <iframe> of <your origin><path> with the same query plus wislab_tenant, wislab_instance, wislab_ts and wislab_sig, inside a sandbox. The signature is bound to the block's path: a src copied from a public page opens that block and nothing else. Your page must let the academy that placed it frame it, with frame-ancestors and its exact origin.

Full pages

  • With proxyPath (for example /proxy), the academy serves your pages at https://<academy>/apps/<handle>/<path>. Wislab forwards the request to <your origin><proxyPath>/<path> with the same query and the signed headers.
  • Your response comes back as a page of its own, with a sandbox CSP and an opaque origin, frame-ancestors 'none' and noindex. No scripts or forms unless the review allowed them. 10 seconds and 2 MB each way; redirects are not followed.
  • x-wislab-user carries a pseudonym of the signed-in visitor: always the same for your app, different for each app, never the real id. It is only sent when the request came from the academy itself; a request another site starts arrives anonymous. Keep your GET routes free of side effects.

Signature v2

  • Every request is signed with your app secret: v2,<base64 HMAC-SHA256> over these fields joined by newlines: v2, tenant, instance, user, timestamp, method, path, query and the SHA-256 of the body (base64url). Ids that do not apply are empty.
  • Server to server, the signature travels in the x-wislab-timestamp, x-wislab-signature, x-wislab-tenant and x-wislab-instance headers (plus x-wislab-user on full pages). In an iframe it travels in the query. Reject requests more than 5 minutes away.

What the academy accepted

What the academy accepted is frozen at install: your app's origin, the full-page path and each block with how it renders and its path. A new block, one switched from html to iframe or moved to another path, a new proxy or another origin is not served until the academy accepts it in Admin > Apps.

Server
import { createHash, createHmac, timingSafeEqual } from "node:crypto";

// Verifies an html block request, an iframe block src or a full-page request.
export function verifyWislabStorefront(secret, url, headers, method = "GET", body = "") {
  const u = new URL(url);
  const fromHeaders = headers.get("x-wislab-signature");
  let tenant, instance, user, ts, query, signature;
  if (fromHeaders) {
    tenant = headers.get("x-wislab-tenant");
    instance = headers.get("x-wislab-instance") ?? "";
    user = headers.get("x-wislab-user") ?? "";
    ts = headers.get("x-wislab-timestamp");
    query = u.search.slice(1);
    signature = fromHeaders;
  } else {
    // An iframe src: the signature covers the query without wislab_sig.
    signature = u.searchParams.get("wislab_sig");
    tenant = u.searchParams.get("wislab_tenant");
    instance = u.searchParams.get("wislab_instance") ?? "";
    user = "";
    ts = u.searchParams.get("wislab_ts");
    u.searchParams.delete("wislab_sig");
    query = u.searchParams.toString();
    method = "GET";
    body = "";
  }
  if (!signature || !tenant || !Number.isInteger(Number(ts))) throw new Error("missing signature");
  if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) throw new Error("stale");

  const bodySha256 = createHash("sha256").update(body).digest("base64url");
  const message = ["v2", tenant, instance, user, ts, method.toUpperCase(), u.pathname, query, bodySha256].join("\n");
  const expected = createHmac("sha256", secret).update(message).digest();
  const valid = signature.split(" ").some((entry) => {
    const [version, value] = entry.split(",");
    if (version !== "v2" || !value) return false;
    const given = Buffer.from(value, "base64");
    return given.length === expected.length && timingSafeEqual(given, expected);
  });
  if (!valid) throw new Error("bad signature");
  return { tenantId: tenant, instanceId: instance || null, userId: user || null };
}

Review and publishing

Your app goes through these states:

States of an app
StateWhat it means
Draft (your academy only)It only works in your academy and visitors see nothing. When it is ready you ask for review; that needs a package with external apps.
In reviewWislab is reviewing it.
Approved (install link)Other academies can install it with your install link.
In the catalogYou asked again and Wislab published it: it shows up in Admin > Apps of every academy.
RejectedWislab did not approve it. You see the reviewer's note with the reason.
SuspendedWislab stopped it: its tokens answer 403, the admin does not open it and no webhooks go out.

Changing on an approved app anything academies consent to (scopes, URLs, webhook, origins, blocks or full pages) sends it back to review. Installations that already exist keep what they accepted.

Automated checks

  • The embedded page answers 2xx within 5 seconds, without redirects.
  • It lets the academy frame it: no X-Frame-Options and a frame-ancestors that names its exact origin, without a wildcard.
  • Each allowed origin is a scheme and a host, nothing else.
  • The webhook URL answers, and it rejects an unsigned delivery with a 4xx.
  • Every scope it asks for exists.
  • If it asks for personal data, the description says what for.

What a person reviews

  • Who registered it: the academy, its package and the person.
  • That the permissions it asks for make sense for what it does, above all the personal data ones.
  • That its blocks and pages do not ask for passwords, cards or sign-in details.
  • That its links and addresses go to its own domain and do not pass for another brand.
  • If it serves full pages, whether they really need forms.

Names that are refused

At registration, the handle and the name cannot pass for Wislab, a payment provider, a well-known brand, a bank, official support or a sign-in page. Words of your own academy are allowed.

Starter template

Wislab has a template, templates/wislab-app/, with files to copy into a Next.js project. They only depend on next, react and jsonwebtoken.

Template files
FileWhat it does
lib/wislab-session.tsVerifies the 60-second session token the admin hands you.
lib/wislab-api.tsTyped client for the API v1 with your installation token and pagination.
lib/wislab-webhooks.tsVerifies the webhook signature, dependency-free.
lib/wislab-storefront.tsVerifies the v2 signature of blocks and full pages, and reads the block settings.
components/WislabBridge.tsxThe postMessage bridge: ready, session, height and notices.
app/embedded/page.tsxThe page the admin embeds (example).
app/api/wislab/session/route.tsTrades the session token for your own session.
app/api/wislab/webhooks/route.tsReceives events.
app/blocks/[id]/route.tsServes an html block (example).
app/proxy/[[...path]]/route.tsServes full pages (example).
.env.exampleThe secrets and where each one comes from.

Before going live

  • The embedded page sends no X-Frame-Options and answers frame-ancestors <wislab_academy> only for an academy where it is installed ('none' otherwise).
  • The embedded session lives per page load, or its cookie is bound to the academy that issued it.
  • Secrets live on the server only: the browser never sees the app secret, the installation token or the webhook secret.
  • You verify the session token on every exchange and never keep it past its exp.
  • Your webhook handler is idempotent on id.
  • Your block and page routes verify the signature on every request, and html blocks answer within 2 seconds.
  • Your full-page GET routes have no side effects.
Template files
templates/wislab-app/
  lib/wislab-session.ts
  lib/wislab-api.ts
  lib/wislab-webhooks.ts
  lib/wislab-storefront.ts
  components/WislabBridge.tsx
  app/embedded/page.tsx
  app/api/wislab/session/route.ts
  app/api/wislab/webhooks/route.ts
  app/blocks/[id]/route.ts
  app/proxy/[[...path]]/route.ts
  .env.example

Building an app and something does not add up?

Tell us your app's handle and what you see, and we'll look at it with you.

Contact us