1. Docs
  2. Authentication
  3. Direct API

Direct API

Own the login UI yourself and call the identity-auth API directly. Use this when you need to render the form on your own domain, embed it inside an existing screen, or coordinate it with custom flows.

Overview

Direct API is the credentials-in / tokens-out path. Your app renders the login form on your own domain, posts the credentials as JSON to POST /v1/identity/auth/login, and receives an access token, refresh token, and a small identity payload. No redirects, no PKCE, no consent screen. Choose this path when you need full control over the sign-in UI, want to keep the user on your domain, or are building a flow Hosted Login doesn't cover (mobile-native sign-in, custom multi-step onboarding, etc.). Password isn't the only way in: the same Direct-API surface also offers passwordless email-OTP login and publishable-key SSO, both returning the same identity session. The trade-off: you're responsible for handling password input safely and for surfacing the registration (when self-signup is enabled), email-verification, password-recovery, and invite-acceptance flows yourself.

Integration modes

Before writing any code, decide how your app will keep sessions alive. Login works from anywhere (the access token travels in the response body), but the refresh token rides in an httpOnly cookie, and which domain that cookie belongs to is what determines your integration mode:

Same-site apps: the browser reaches Canopy on the same site your app is on, because you route us through your own domain with a reverse proxy you run. The browser-direct flow documented on this page works as-is: the refresh cookie is same-site and silent refresh just works.Cross-domain browser apps: point a custom auth domain at Canopy (recommended). Delegate a subdomain you own, say auth.yourapp.com, and call it instead of the platform origin. The refresh cookie is then first-party on your own site, so silent refresh works in every browser and you write no backend auth code. See Custom auth domain below.Cross-domain browser apps that can't delegate a subdomain: if DNS is out of your hands, integrate through the Backend Proxy (BFF) pattern instead: your backend proxies the auth endpoints and owns the refresh cookie on your domain.Native mobile apps: a phone has no cookie jar, so register a native publishable key for the app. Sessions created with it return the refresh token in the response body for you to store in the OS keychain or keystore, instead of a cookie. Storage becomes your app's responsibility; the Backend Proxy (BFF) pattern remains an option if you would rather your server hold the token.

Nothing on this page fails at build time in a cross-domain setup: curl and same-origin dev servers work perfectly. The breakage only shows up in real browsers on your production domain, so pick the mode deliberately before you ship.

Custom auth domain

A custom auth domain makes Canopy answer on a hostname you own. Your users' browsers see the refresh cookie as first-party, which is what keeps silent refresh working in browsers that block third-party cookies. One domain per Environment, included on every plan.

In the Console open your Environment → IntegrationsCustom Auth Domain and add a subdomain you control (auth.yourapp.com). Apex domains are rejected: they can't carry the required CNAME.Publish the DNS records the page lists: a CNAME pointing the subdomain at Canopy, and a TXT record at _canopy-verify.<your-domain> proving you control it.Click Verify. Verification is fail-closed: it only succeeds once the TXT challenge actually resolves, so nothing goes live by accident.Wait for the certificate. Issuance is automatic and the page updates itself when the status reaches Active; there is nothing to poll and no button to press.

Once the domain is Active, change the base URL your frontend posts to: https://auth.yourapp.com/v1/identity/auth/login rather than the platform origin. Every request and response is otherwise identical to the browser-direct flow on this page, and the domain is automatically an allowed CORS origin, so you don't have to register it separately.

Send the publishable key belonging to the Environment the domain serves. A key that names a different Environment is refused with auth.domain_host_key_mismatch rather than quietly honored. A host and a key that disagree are never reconciled.

Prerequisites

Direct API has a much smaller setup than Hosted Login: no OAuth client, no redirect URIs, no scopes. You need your environment's publishable key (the non-secret pk_… value that tells Canopy which Environment a login is for) and where Canopy issues its tokens from.

Login

The entry point for Direct API. Your form posts JSON to POST /v1/identity/auth/login; Canopy returns the access token plus a small identity payload. The refresh token is set on an httpOnly cookie, so your client never sees the raw value.

POST /v1/identity/auth/login

Auth: Public: no API key, no JWT. The credentials in the request body are the auth.

Throttle: 5 requests per 15 minutes per IP. Tune your retry strategy accordingly.

Request

curl -X POST https://auth.canopy-io.com/v1/identity/auth/login \
  -H "Content-Type: application/json" \
  -c cookies.txt \
  -d '{
    "publishable_key": "pk_3f9a8b2c1d4e5f60718293a4b5c6d7e8",
    "email": "alex@acme.com",
    "password": "correct horse battery staple"
  }'

Response: access token issued

{
  "data": {
    "requires_mfa_challenge": false,
    "access_token": "eyJ...",
    "token_type": "Bearer",
    "expires_in": 900,
    "identity": {
      "id": "id_01HXABC...",
      "email": "alex@acme.com",
      "first_name": "Alex",
      "last_name": "Singh"
    }
  }
}

# Set-Cookie: ca_identity_refresh_token=...; HttpOnly; Path=/v1/identity/auth; Secure

Note the { data } envelope: Direct API uses Canopy's standard response shape. The identity field carries the same profile info an ID token would; populate your UI from it without a follow-up /me call. The refresh token is never in the response body. It's set on the ca_identity_refresh_token httpOnly cookie (scoped to /v1/identity/auth) so an XSS sink on your origin can't read it. If the Environment requires MFA and the identity has an enrolled factor, login returns requires_mfa_challenge: true plus an mfa_challenge object instead of an access token. Complete the challenge at /v1/identity/auth/mfa/challenge/* to mint the session. If MFA is mandatory but the identity has no enrolled factor yet, login instead returns requires_mfa_enrollment: true with a sealed mfa_enrollment token: enroll a first factor via /v1/identity/auth/login/mfa-enroll/* without an interim session.

Passwordless login (email OTP)

A passwordless alternative to POST /login: the identity gets a one-time code by email and exchanges it for a session, with no stored password. Opt-in per Environment (email_otp_login_enabled, off by default); while it's off the endpoints return 403 before any identity lookup, so a disabled-method probe can't be used to enumerate accounts.

Method Path Description
POST/v1/identity/auth/login/email-otp/startBody: { publishable_key, email, turnstile_token? }. Emails a 6-digit code (15-minute TTL, single-use) and always returns a generic "code sent", unknown, unverified, and inactive identities included (anti-enumeration). Returns 403 only when email-OTP login is disabled for the Environment.
POST/v1/identity/auth/login/email-otp/verifyBody: { publishable_key, email, code }. 5-attempt budget; clears the code and mints the same session shape as password login: the same MFA gate applies, so a high-assurance Environment can still return requires_mfa_challenge. The access token carries amr: ["otp"].

Use this for magic-code sign-in or a fully passwordless Environment (set password_login_enabled: false). When self-signup is enabled the first verified code also provisions the identity: signup and first sign-in collapse into one step.

SSO (publishable key)

Federated sign-in for a publishable-key app: no OAuth client, no second token system. Your login UI asks Canopy whether an email routes to an SSO connection, hands the browser to the customer's IdP, and exchanges the returned single-use token for the same identity session as password login. Available once an admin binds an active end-user SAML or OIDC connection to the Environment.

The flow

Resolve: POST /v1/identity/auth/sso/resolve with { publishable_key, email } returns { sso_available, connection_id?, type? }. An unrecognized domain returns sso_available: false; fall back to your password or email-OTP form.Start: redirect the browser to the start endpoint for the returned type with publishable_key, your return_url, and a CSRF state. Canopy validates return_url against the Environment's allowlist, then redirects to the IdP.Return: the IdP round-trip lands back on your registered return_url with ?sso_token=…&state=…. Verify the echoed state matches what you sent.Authenticate: POST /v1/identity/auth/sso/authenticate with { publishable_key, sso_token } returns the same session shape as password login (and honors the same MFA gate).

Endpoints

Method Path Description
POST/v1/identity/auth/sso/resolveBody: { publishable_key, email }{ sso_available, connection_id?, type? }. Tells your UI whether to hand off to SSO or show the password field. Anti-enumeration: an unrecognized domain returns sso_available: false. Throttled to 20/min.
GET/v1/identity/auth/sso/saml/:connectionId/startBegin SAML SSO: redirect the browser here with publishable_key, return_url, and state query params. Validates return_url against the Environment's allowlist, then redirects to the IdP with a signed AuthnRequest.
GET/v1/identity/auth/sso/oidc/:connectionId/startBegin OIDC SSO: same query params as the SAML start; redirects to the IdP with RP-side PKCE + nonce.
POST/v1/identity/auth/sso/authenticateBody: { publishable_key, sso_token }. Exchanges the single-use token from the return URL for the same session shape as password login. Throttled to 20/min.

The return_url must be registered on the Environment's SSO return-URL allowlist (set in the Console). start rejects anything else before redirecting, so the IdP round-trip can't be steered to an attacker origin. No token ever travels in the redirect URL; only the single-use sso_token does. The issued token's amr reflects the IdP: an OIDC IdP that performed MFA yields ["sso", "mfa"]; SAML stays ["sso"].

Self-service signup

When an admin enables self-signup for the Environment, your app can register a new identity directly with the publishable key: no API key, no invite. The new identity is created unverified and must confirm their email (see Email verification & invites below) before login succeeds. Optional controls (a CAPTCHA requirement and a daily cap) are configured per Environment, and the Environment's allowed web origins (the CORS list) also gate which browser origins may call register.

Method Path Description
POST/v1/identity/auth/registerRegisters a new identity for the Environment named by the publishable key. Body: { publishable_key, email, password, first_name?, last_name?, turnstile_token? }. Always returns a generic success and always sends exactly one email (a verification link for a new address, or a "was this you?" notice to the existing owner when the address is already registered), so neither the response, its timing, nor mail volume reveals whether an address exists (anti-enumeration). Your UI shows the same "check your email" message either way. Throttled to 5 per hour per IP. Returns 403 when self-signup is disabled, the request Origin isn't allowlisted, or a required CAPTCHA token is missing/invalid; 429 when the Environment's daily signup cap is reached.

Always returns a generic success, even when the email already exists (anti-enumeration), so your UI shows the same "check your email" message either way. The new identity is created unverified and must confirm their email (see Email verification & invites below) before login succeeds.

What's in the tokens

Two values are returned inside the { data } envelope: an access token (JWT) and a refresh token (opaque). Direct API does not return an ID token. The identity field on the login response carries the equivalent profile data.

Access token

Sent on every authenticated API call as Authorization: Bearer …. 15-minute lifetime: short enough that revocation isn't necessary; let it expire and refresh.

Claim Type Description
sub string The authenticated identity's UUID. Use this as your stable user identifier.
account_id / application_id / environment_id string The Account, Application, and Environment the identity is acting in, fixed by the publishable key at login, so they're always present on Direct API access tokens. Each is also stamped as a matching _slug claim for URL building without an extra lookup.
type string Always identity. Distinguishes from platform-user tokens issued elsewhere.
amr string[] Authentication methods that were satisfied: pwd (password), otp (email OTP), mfa (a second factor cleared), hwk (WebAuthn), rba (recovery code), sso (federated). A federated login carries ["sso"], or ["sso", "mfa"] when the IdP itself asserted MFA.
iat / exp number Issued-at and expires-at as Unix epoch seconds. exp is always 15 minutes after iat.
iss string https://auth.canopy-io.com: verify this matches exactly.
kid (header) string Key ID in the JWT header. Used to pick the right public key from the JWKS endpoint.
org_id / org_role string Present only when the Environment's organizations container is on, for an identity that belongs to at least one organization: the organization the session is acting in, and the name of the one role held there. Changed by the switch-organization endpoint, never by anything a request sends.
Refresh token

An opaque string (not a JWT). Backend-only: store it next to the user's session and trade it for a new access token when the current one expires. See Refresh tokens below for rotation rules.

Identity payload

The login response includes an identity sub-object with id, email, first_name, and last_name. This is your shortcut to populate the user's name in your UI without making a second call to /me. It's not signed and not a token: treat it as response data, not as authority. For anything authorization-sensitive, verify the access token instead.

Verifying tokens

JWT verification is identical to Hosted Login: same JWKS endpoint, same RS256 signing, same issuer claim. The verification code below works for tokens from either flow.

Fetch the JWKS from https://auth.canopy-io.com/.well-known/jwks.json.Look at the JWT's kid header. Find the matching key in the JWKS by kid.Verify the RS256 signature locally with that public key.Reject if iss isn't https://auth.canopy-io.com or exp is in the past. Direct API tokens don't carry an aud claim (no OAuth client to bind to), so skip the aud check.Cache the JWKS in memory. On a kid miss, refetch once: Canopy may have rotated keys.

Node: using the jose library

import { createRemoteJWKSet, jwtVerify } from 'jose';

const JWKS = createRemoteJWKSet(
  new URL('https://auth.canopy-io.com/.well-known/jwks.json')
);

const { payload } = await jwtVerify(accessToken, JWKS, {
  issuer: 'https://auth.canopy-io.com',
});

// payload.sub, payload.account_id, payload.application_id, payload.environment_id are now trusted.

Refresh tokens

Refresh tokens last 180 days and are rotated on every use. Each call to /v1/identity/auth/refresh reads the current refresh token from the ca_identity_refresh_token httpOnly cookie, issues a new access token, and sets a new refresh-token cookie that replaces the old one. The previous cookie value is dead the moment the response is sent.

POST /v1/identity/auth/refresh

Auth:The ca_identity_refresh_token cookie is the auth. No body, no Authorization header. The cookie is scoped to Canopy's origin, so in a cross-domain browser app it is third-party and will be blocked; see Backend Proxy (BFF), where your backend replays the refresh token in the request body instead.

Throttle: 60 requests per minute per IP.

Refresh request

curl -X POST https://auth.canopy-io.com/v1/identity/auth/refresh \
  -b cookies.txt \
  -c cookies.txt

# 200 OK
# {
#   "data": {
#     "access_token": "eyJ...",
#     "token_type": "Bearer",
#     "expires_in": 900
#   }
# }
#
# Set-Cookie: ca_identity_refresh_token=NEW; HttpOnly; Path=/v1/identity/auth; Secure

If a refresh-token cookie value is presented twice (e.g. you cached it elsewhere and replayed the old one), Canopy treats it as theft and revokes every refresh token issued to that identity. The access token isn't revoked server-side (it expires within 15 minutes), so the user has to sign in again once it lapses. Let the cookie do its job: don't read, store, or echo the refresh token from your own code.

Logout

Logout requires a valid access token (so Canopy knows whose session to end). The refresh token to revoke is read from the same ca_identity_refresh_token cookie, or from refresh_token in the body for a client that holds its own token: a backend proxy, or a native app whose key delivers it in the body. Only that session ends. Send neither and every session of the identity is revoked. The matching access token isn't revoked. It expires on its own within 15 minutes.

POST /v1/identity/auth/logout

Auth: Identity JWT (Bearer) plus the refresh token, in the cookie or in the body. Public clients can't call this: only the authenticated identity can end their own sessions.

curl -X POST https://auth.canopy-io.com/v1/identity/auth/logout \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -b cookies.txt \
  -c cookies.txt

# 200 OK
# { "data": { "message": "Logged out" } }
#
# Set-Cookie: ca_identity_refresh_token=; HttpOnly; Path=/v1/identity/auth; Max-Age=0

# A client holding its own token names it instead of sending a cookie
curl -X POST https://auth.canopy-io.com/v1/identity/auth/logout \
  -H "Authorization: Bearer ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"refresh_token":"REFRESH_TOKEN"}'

The response clears the refresh-token cookie. For a hard logout, also clear any local session storage your app maintains. The 15-minute access-token TTL bounds the worst-case window for any in-flight requests.

More endpoints

Beyond the sign-in flow, the Direct API surface includes account management (for the signed-in identity), self-service signup, password recovery, and the email-verification + invite-acceptance endpoints. Each group is self-contained, so open the section you need.

Error responses

Direct API errors use Canopy's standard response envelope, which differs from Hosted Login's RFC 6749 OAuth shape. Every error body has a code for programmatic handling and a message for display, plus the originating request's path, method, and timestamp for debugging.

Sample error response

{
  "error": {
    "statusCode": 401,
    "code": "auth.invalid_credentials",
    "message": "Invalid email or password",
    "timestamp": "2026-04-04T01:23:45.678Z",
    "path": "/v1/identity/auth/login",
    "method": "POST"
  }
}
Status When it fires
400Validation failure: a required field is missing or malformed (no email, no password, no publishable_key, password too short or too long, etc.). Per-field details are in the response's details array.
401Wrong credentials, expired access token, missing bearer, or an Identity JWT presented to a public-only endpoint.
403Wrong principal type (a portal user JWT on an Identity-only endpoint), or a self-signup register call rejected because self-signup is disabled for the Environment, the request Origin isn't allowlisted, or required CAPTCHA verification failed.
404A token doesn't match a known record: an invite, email-verification, or password-reset token that's unknown or expired. (An unknown publishable key or an unrecognized login email returns a generic 401, not 404, because Canopy doesn't reveal whether an Environment or identity exists.)
429Throttle limit hit (each endpoint has its own bucket, see the per-section throttle notes above), or the Environment's self-signup daily cap was reached.

Gotchas

Things that catch teams switching from Hosted Login or building their first Direct API integration.

Response envelope is wrapped in { data }. Direct API uses Canopy's standard envelope, not the OAuth raw shape: response.data.access_token, not response.access_token.No ID token. Use the identity sub-object on the login response for the user's profile; it covers the same fields an OIDC ID token would.Tokens have no aud claim. There's no OAuth client to bind to. Skip the audience check during JWT verification and check iss only.The publishable key is required on every public credential endpoint. Login, register, forgot-password, and resend-verification all need your environment's pk_… key, sent as a publishable_key field in the JSON request body (not a header). Bake it into your frontend config. A wrong key fails login with the same generic 401 as wrong credentials — verify the key itself with GET /v1/identity/auth/config?publishable_key=…, which answers a distinct 400 for an unknown key.Self-signup is per-Environment and off by default. POST /v1/identity/auth/register only works once an admin enables it for the Environment (it 403s otherwise). It always returns a generic success, even when the email already exists, so show "check your email to finish signing up" regardless; the response never reveals whether the address was already registered. Optional CAPTCHA and daily-cap controls, plus the Environment's allowed web origins (CORS), are configured per Environment.Refresh tokens live in an httpOnly cookie. The login response doesn't return refresh_token in the body. It sets ca_identity_refresh_token (scoped to /v1/identity/auth). Your client never sees the raw value; just pass cookies along on every refresh/logout call.MFA can interrupt login, and switching organizations. Whenever the identity has an enrolled factor, login returns requires_mfa_challenge: true with an mfa_challenge object instead of an access token, whether or not the Environment requires MFA: an enrolled factor is always asked for. The same shape can come back from switch-organization when the target organization requires MFA the session has not presented. Complete the challenge at /v1/identity/auth/mfa/challenge/* before you get a session, and handle it on every Environment, not only the ones where MFA is required. A switch into an organization that requires MFA of a member with no factor goes through under the Environment's grace window and says so, with mfa_enrollment_pending: true and a grace_expires_at, exactly as login does; once the window has passed the switch is refused with organization.mfa_enrollment_required, and the person enrols from the session they already hold.Logout requires a bearer token plus the refresh token. The refresh token, in the cookie or in the body, tells Canopy which session to revoke; the bearer tells it whose. With neither, every session of the identity is revoked, so a native client that forgets the body token signs the person out of every device.Throttle limits are per endpoint, per IP. Login is the strictest (5 per 15 minutes). Don't auto-retry on 429 without a backoff.Refresh-token reuse triggers chain revocation. Same as Hosted Login. Don't copy the cookie value into your own storage and replay it: let the browser manage the cookie.Forgot-password always returns 200. The endpoint deliberately doesn't reveal whether an email matched a registered identity, so your UI should show a generic "if that email exists, we sent a reset link" message to match.Password isn't the only login method. Email-OTP login and SSO mint the same session shape, and each is toggled per Environment (password_login_enabled, email_otp_login_enabled, plus any bound SSO connection). A request to a disabled method 403s before any identity lookup.SSO returns a one-time token, not a session body. The IdP round-trip lands on your allowlisted return URL with ?sso_token. Exchange it at /sso/authenticate. Tokens never ride in the redirect URL.Login can require a CAPTCHA too. Independent of self-signup, an Environment can set login_require_captcha. Embed the API-served <canopy-captcha> widget and pass its token on the login call.Passkeys need your domain named on the Environment. The WebAuthn endpoints issue options for the Environment's relying party, and the browser refuses them unless your page's domain matches it. Set webauthn_rp_id and webauthn_origins on the Environment before allowing webauthn in the factor allowlist for an app on your own domain. See Passkeys and your domain.
Environment
API version
v1.0
On this page Was this page helpful?

Tell us how we can improve this guide.