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:
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.
auth.yourapp.com). Apex domains are rejected: they can't carry the required CNAME.CNAME pointing the subdomain at Canopy, and a TXT record at _canopy-verify.<your-domain> proving you control it.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/loginAuth: 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/start | Body: { 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/verify | Body: { 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
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 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_url with ?sso_token=…&state=…. Verify the echoed state matches what you sent.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/resolve | Body: { 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/start | Begin 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/start | Begin OIDC SSO: same query params as the SAML start; redirects to the IdP with RP-side PKCE + nonce. |
POST | /v1/identity/auth/sso/authenticate | Body: { 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/register | Registers 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.
https://auth.canopy-io.com/.well-known/jwks.json.kid header. Find the matching key in the JWKS by kid.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.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/refreshAuth: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/logoutAuth: 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 |
|---|---|
400 | Validation 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. |
401 | Wrong credentials, expired access token, missing bearer, or an Identity JWT presented to a public-only endpoint. |
403 | Wrong 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. |
404 | A 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.) |
429 | Throttle 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.
Tell us how we can improve this guide.