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.). The trade-off: you're responsible for handling password input safely and for surfacing the email-verification, password-recovery, and invite-acceptance flows yourself.

Prerequisites

Direct API has a much smaller setup than Hosted Login — no OAuth client, no redirect URIs, no scopes. You need to know which Account the identity belongs to (specifically its <code>account_slug</code>) 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 — 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 '{
    "account_slug": "acme-prod",
    "email": "alex@acme.com",
    "password": "correct horse battery staple"
  }'

Response — fully bound (single Application)

{
  "data": {
    "requires_application_selection": 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 identity has access to more than one Application in the Account, login returns requires_application_selection: true + an applications array + a pre-auth cookie — render an App picker and POST { account_id, application_id } to /v1/identity/auth/select-application to mint the full access token.

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. Always present on Direct API tokens once an Application is bound (post-login or post-/select-application). 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.
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.
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 <code>ca_identity_refresh_token</code> cookie is the auth. No body, no Authorization header.

Throttle: 10 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 revokes every token in the chain — the current access token, the refresh token, and any tokens still in flight from the same lineage. The user has to sign in again. 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 is read from the same <code>ca_identity_refresh_token</code> cookie and revoked server-side. 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 cookie. 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

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), password recovery, and the email-verification + invite-acceptance endpoints. Each group is self-contained — open the section you need.

Error responses

Direct API errors use Canopy's standard response envelope — different 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 — required field missing, malformed account_slug (must be lowercase alphanumeric with optional dashes), 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 — happens if you accidentally pass a portal user JWT to an Identity-only endpoint.
404Account not found (no account_slug match), identity not found in this Account, or invite token doesn't match a known invite.
429Throttle limit hit. Each endpoint has its own bucket — see the per-section throttle notes above.

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 — only check iss.The account_slug is required on every public credential endpoint. Login, forgot-password, and resend-verification all need it. Bake it into your frontend config.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.Multi-Application identities require a select-application step. If requires_application_selection: true comes back on login, the response carries an applications array and a pre-auth cookie — your SPA picks one and POSTs { account_id, application_id } to /v1/identity/auth/select-application to mint the actual access token.Logout requires a bearer token plus the refresh cookie. The cookie tells Canopy which refresh chain to revoke; the bearer tells it whose session to end.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 — your UI should show a generic "if that email exists, we sent a reset link" message to match.