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/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 '{
"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; SecureNote 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.
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 <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; SecureIf 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/logoutAuth: 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=0The 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 |
|---|---|
400 | Validation 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. |
401 | Wrong credentials, expired access token, missing bearer, or an Identity JWT presented to a public-only endpoint. |
403 | Wrong principal type — happens if you accidentally pass a portal user JWT to an Identity-only endpoint. |
404 | Account not found (no account_slug match), identity not found in this Account, or invite token doesn't match a known invite. |
429 | Throttle 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.