Partner integrations

Sign in with AcademyOS

OAuth 2.0 authorization code + PKCE with OpenID Connect, so learners sign in on your site with their AcademyOS account and consent to what you read.

How it fits together

With the partner API you act as yourself: an organisation-level key lists your licensed catalog and grants access to your customers. Sign in with AcademyOS is the opposite direction: an AcademyOS learner signs in on your site and consents to you reading their identity β€” and, with an extra scope, the marketplace courses they hold. You never see their password; they can revoke you at any time; every token you hold is scoped to exactly what they agreed to.

It is standard OAuth 2.0 authorization code + PKCE with OpenID Connect on top (discovery, RS256 id_tokens, JWKS, userinfo). Any off-the-shelf OIDC client library works: point it at the discovery document and you are most of the way done.

PARTNER Β· MARKETPLACE ACADEMYOS Β· PLATFORM Storefront β€” all courses works for visitors, no sign-in needed GET /api/partners/v1/courses catalog licensed to the partner Β· price, cover, access terms KEY GET Β· partner key Sign in / Sign up one β€œSign in with AcademyOS” button the only registration point β€” no local accounts GET /oauth/authorize sign-in or account creation + consent (code + PKCE) POST /oauth/token id_token (sub, verified email) + access + refresh LEARNER top-level redirect code β†’ tokens My courses the learner's whole shelf, not only your sales GET /api/oauth/v1/me/enrollments the learner's courses Β· enrollments:read scope LEARNER Bearer Β· learner token Course page GET /api/partners/v1/courses/{id} cross-checks the learner's shelf and decides: enrolled? β†’ access Β· not yet? β†’ buy already enrolled? Partner checkout payment and pricing stay on the partner's side the receipt email stays the partner's too buy POST /api/partners/v1/enrollments { course_id, verified email, external_reference } β†’ status: "active" instantly β€” no invite, no email KEY POST Β· ref = order id Course on AcademyOS content is always consumed inside AcademyOS access plain link, session exists instant access
Two credentials, two lanes: the partner API key (server-side, your organisation) powers the catalog and access grants; the learner's OAuth token (issued with consent) powers everything that belongs to the learner.

Registration

Register your partner organisation in the developer portal β€” a signed-in AcademyOS account with a confirmed email is all it takes β€” and create your client there. Clients work immediately; every use of one is gated by the learner's consent. Catalog licensing for the partner API key is a separate, commercial decision you request from the same page. You will need:

  • Your redirect URIs β€” exact match, https required outside development (loopback IPs such as http://127.0.0.1:4567/callback are accepted for local testing).
  • Whether your client is confidential (you can keep a secret server-side: a classic web app) or public (SPA or mobile app; no secret, PKCE carries the proof). The client type cannot be changed later β€” a different type is a new client.
  • Optionally, a narrower scope set than the default four (see Scopes).
  • The name to show on the consent screen, and a technical contact email.

You receive a client_id, and for confidential clients a client_secret that is shown exactly once, at creation β€” we store only its SHA-256 digest, exactly like partner API keys. Lost secrets are rotated, not recovered; rotation re-displays a new secret once and invalidates the old one immediately.

One partner can hold several clients (one per platform). Every client, token and consent of yours dies the moment your partner account is deactivated β€” the same kill switch as your API keys.

Register your app

Endpoints

EndpointPurposeCross-origin
GET /.well-known/openid-configurationOIDC discovery β€” start hereCORS open
GET /oauth/authorizeConsent screen (top-level browser navigation, never an iframe or XHR)β€”
POST /oauth/tokenCode β†’ tokens; refreshCORS open
POST /oauth/revokeRFC 7009 token revocationCORS open
GET/POST /oauth/userinfoOIDC userinfo (Bearer token)CORS open
GET /oauth/discovery/keysJWKS β€” the RS256 public key for id_token verificationCORS open
GET /api/oauth/v1/meUserinfo mirror in the resource-API namespaceβ€”
GET /api/oauth/v1/me/enrollmentsThe learner's marketplace coursesβ€”

CORS is deliberately open only on the cookie-less, public endpoints a browser-based PKCE client must call; everything else keeps the browser's same-origin default. /oauth/authorize is a redirect target, not an API β€” do not fetch it.

Scopes

ScopeGrantsShown to the learner as
openidThe id_token and the sub claim. Always required (it is the default scope)Confirm who you are on AcademyOS
emailemail, email_verified β€” mirrored into the id_token as well as userinfo, so plain sign-in needs no userinfo round-tripSee your email address
profilename, given_name, family_name, preferred_username, locale, picture β€” userinfo onlySee your basic profile (name, username, language, picture)
enrollments:readGET /api/oauth/v1/me/enrollmentsSee the courses you have access to

Note the colon in enrollments:read. Unknown scopes are rejected with the standard invalid_scope error, and a per-client configuration can narrow this list further. Request the smallest set you need: the consent screen shows every scope to the learner, and asking for enrollments:read you don't use costs you consent-screen trust.

The authorization flow

Authorization code with PKCE (S256). PKCE is required β€” public clients cannot complete the flow without it, and confidential clients that send it get it verified. No implicit, no password, no client-credentials grant: a partner acting as itself uses its partner API key, not OAuth.

  1. Redirect the learner (top-level) to:

    HTTP
    GET https://app.academyos.app/oauth/authorize?client_id=…&redirect_uri=…&response_type=code
        &scope=openid+email+enrollments:read
        &state=<random>&nonce=<random>
        &code_challenge=<S256(code_verifier)>&code_challenge_method=S256
  2. The learner signs in to AcademyOS if they aren't already β€” including their 2FA step β€” and lands back on the consent screen automatically. The screen names your app and your partner organisation and lists the scopes. They approve or deny.

  3. Approval redirects to your redirect_uri with ?code=…&state=…. The code is single-use and lives 10 minutes.

  4. Exchange it (server-side, or from the SPA β€” the endpoint is CORS-open):

    HTTP
    POST https://app.academyos.app/oauth/token
    Content-Type: application/x-www-form-urlencoded
    
    grant_type=authorization_code&code=…&redirect_uri=…
    &client_id=…&code_verifier=…
    [&client_secret=… for confidential clients]
    JSON
    {
      "access_token": "…", "token_type": "Bearer", "expires_in": 7200,
      "refresh_token": "…", "scope": "openid email enrollments:read",
      "created_at": 1724751600, "id_token": "eyJhbGciOiJSUzI1NiIs…"
    }

Confidential clients may authenticate at the token endpoint with HTTP Basic (client_secret_basic) or form parameters (client_secret_post); both are accepted.

The first authorization always shows the consent screen. A confidential client is not re-prompted while a still-live token from an earlier consent carries exactly the scope set it asks for again β€” the authorization redirects straight back with a fresh code. Anything else shows the screen again: a different scope set (broader or narrower), no surviving token (all expired or revoked), and every authorization from a public client β€” without a secret there is no proof it is the same app, so consent is per-authorization there. Denial redirects with the standard error=access_denied. prompt=login and a stale max_age force re-authentication.

The id_token

RS256-signed JWT; verify it against the JWKS endpoint, and validate iss (the issuer above), aud (your client_id), exp (10 minutes), and nonce (echoes yours).

  • sub is the learner's stable public id (user_…) β€” opaque, non-enumerable, permanent per user. Key your local accounts on sub, never on email: an email can change, sub cannot.
  • auth_time is when the learner last actually authenticated, not when the session was restored.
  • With the email scope, email / email_verified ride in the id_token directly.

Token lifecycle

CredentialLifetime
Authorization code10 minutes, single-use
Access token2 hours
id_token10 minutes (an authentication assertion, not an access credential)
Refresh tokenNo clock expiry β€” superseded by rotation (see below), dies on revocation or partner deactivation

Refresh rotates with a hand-off window: exchanging a refresh token returns a new access + refresh pair, and the pair it replaces stays valid until the new access token is first used β€” a token response lost on the network cannot lock you out, because retrying the exchange with the old refresh token still works until then. From the first use of the new access token, the replaced pair is revoked and replaying its refresh token is refused (invalid_grant). Store the newest pair, always.

HTTP
POST https://app.academyos.app/oauth/token
grant_type=refresh_token&refresh_token=…&client_id=…[&client_secret=…]

Revoke tokens you no longer need (sign-out, account unlink) via RFC 7009:

HTTP
POST https://app.academyos.app/oauth/revoke
token=…&client_id=…[&client_secret=…]

Tokens are stored as SHA-256 digests, like every other credential we hold for you.

The resource API

Authorization: Bearer <access_token> against /api/oauth/v1. This is a third credential regime: partner API keys and AcademyOS mobile-app tokens are not accepted here, and OAuth tokens are not accepted on the partner API or the mobile API. Errors use the familiar shape:

HTTPcodeMeaning
401unauthorizedMissing/expired/revoked token β€” or the partner account is deactivated
403insufficient_scopeLive token, but not this scope. WWW-Authenticate names the scope needed

GET /api/oauth/v1/me

Scope: openid. The OIDC userinfo claims, filtered by the token's scopes, in the resource-API namespace so you integrate against one base path:

JSON
{ "sub": "user_9tRk…", "email": "learner@example.com", "email_verified": true, "name": "Ada Learner" }

GET /api/oauth/v1/me/enrollments

Scope: enrollments:read. The learner's marketplace courses β€” byte-identical in shape to what the learner's own AcademyOS mobile app shows them, because both render the same feed. Two kinds of row, distinguished by kind:

JSON
{
  "marketplace_enrollments": [
    {
      "kind": "purchase",
      "order_id": "mord_…", "order_status": "paid",
      "purchased_at": "2026-08-11T14:03:22Z", "granted_at": null,
      "product_id": "prod_…", "name": "Practical Math", "slug": "practical-math",
      "creator_name": "Creator One Studio", "cover_image_url": "https://…",
      "access_state": "active", "access_expires_at": "2026-09-10T14:03:22Z",
      "days_remaining": 14, "enrollment_status": "active"
    },
    {
      "kind": "grant",
      "order_id": null, "order_status": null,
      "purchased_at": null, "granted_at": "2026-08-12T09:00:00Z",
      "product_id": "prod_…", "name": "…", "slug": "…",
      "creator_name": "…", "cover_image_url": "https://…",
      "access_state": "active", "access_expires_at": null,
      "days_remaining": null, "enrollment_status": "active"
    }
  ]
}
  • kind: "purchase" β€” a course the learner bought on AcademyOS. kind: "grant" β€” a course granted order-lessly, e.g. by a partner through the partner API or by an AcademyOS admin. A course covered by both appears once, as the purchase.
  • access_state is one of active / pending / expired / revoked / none; days_remaining is null for lifetime access. pending on a purchase is a checkout still in flight (on a grant, an invitation not yet accepted); none is a purchase that never completed β€” a failed, canceled or abandoned checkout still appears as a row, and your UI should treat it as no access.
  • The feed answers for the learner who consented, whoever granted the access. It is not scoped to your grants and does not say who sold what β€” it is the learner showing you their own shelf.
  • No money fields: what the learner paid is between them and whoever they bought from.

What the learner sees and controls

  • The consent screen is branded, localized (en/es/pt), and names both your application and your partner organisation.
  • Learners review every app they have authorized under Settings β†’ Authorized Apps, and can revoke one at any time. Revocation kills all your tokens for that learner at once; the next authorize round-trip starts from a fresh consent screen. Build for tokens dying at any moment β€” the 401 tells you to re-authorize.
  • An AcademyOS administrator impersonating a learner cannot consent on their behalf; the flow refuses.

Kill switch, rate limits and the fine print

  • Partner deactivation is total: authorize renders the standard OAuth error page, the token endpoint answers invalid_client, and every already-issued access/refresh token answers 401 invalid_token from the same moment. Reactivation restores tokens that haven't expired.
  • /oauth/authorize is rate-limited per IP (default 30/min), /oauth/token per IP (default 60/min); 429 with a plain body. These bound credential-stuffing, not your legitimate traffic.
  • Redirect URIs are exact-match and https-only outside development, except loopback-IP callbacks (http://127.0.0.1:<port>/…), whose port may vary at authorize time per RFC 8252. No wildcard URIs, no fragments.
  • The authorize endpoint must be a top-level navigation β€” it depends on the learner's AcademyOS session cookie, which does not exist in cross-site iframe or fetch contexts.
  • There is no token introspection endpoint; identity comes from the id_token and userinfo.

Integration checklist

  1. Register your organisation and client in the developer portal; store the client_id (and one-shot client_secret, if confidential) in your secret manager.
  2. Point an OIDC client library at https://app.academyos.app/.well-known/openid-configuration.
  3. Send learners through authorize with PKCE + state + nonce; validate everything on the way back.
  4. Key your accounts on sub. Treat email as display data unless email_verified is true.
  5. Ask for enrollments:read only if you actually render the learner's courses.
  6. Refresh proactively (access tokens live 2 hours), store the rotated pair, and treat any 401 as "send the learner through authorize again".
  7. Revoke tokens on sign-out/unlink instead of letting them age out.

Run the example client

The AcademyOS repository ships a framework-free reference client at examples/oauth-client/app.rb that walks this whole page in a real browser: discovery, PKCE, the callback, the code exchange as a confidential client, id_token verification against the JWKS, the three resource calls, refresh rotation and revocation. Every page it renders prints the raw upstream responses, so it doubles as a debugging tool for your own integration.

BASH
cp .env.example .env   # ACADEMYOS_ISSUER, CLIENT_ID, CLIENT_SECRET, REDIRECT_URI, SESSION_SECRET
ruby app.rb            # then open http://localhost:4567 and click "Sign in with AcademyOS"

Against a deployed AcademyOS the redirect URI must be https (or a loopback IP): put the client behind a tunnel such as cloudflared tunnel --url http://localhost:4567 and register the tunnel's callback URL. The README next to the script carries the evidence checklist we run ourselves.

Was this page useful?

Last updated September 04, 2026