Authentication
Section titled “Authentication”The Hoody authentication API covers email/password sign-in, OAuth sign-in flows (PKCE for first-party web, GitHub and Google providers, an RFC-8628-inspired device flow for CLIs, and a popup-handoff launch flow), session and refresh-token management, and TOTP two-factor authentication. Use these endpoints to drive browser sign-in, integrate the CLI device flow, manage long-lived sessions, and gate sensitive token mutations behind a second factor. The control-plane surface (https://api.hoody.com) handles every flow below; container-scoped resources use the {projectId}-{containerId}-{service}-{instance}.{server}.containers.hoody.com hostname template.
Public sign-in configuration
Section titled “Public sign-in configuration”GET /api/v1/auth/available-regions
Section titled “GET /api/v1/auth/available-regions”Returns regions where free-tier servers exist, with boolean availability. Public, no auth required.
This endpoint takes no parameters.
Response
Section titled “Response”{ "statusCode": 200, "data": { "regions": [ { "region": "us-east", "country": "US", "city": "New York", "available": true }, { "region": "eu-west", "country": "DE", "city": "Frankfurt", "available": true }, { "region": "ap-southeast", "country": "SG", "city": "Singapore", "available": false } ] }}curl https://api.hoody.com/api/v1/auth/available-regionsimport { HoodyClient } from 'hoody-sdk';
const client = new HoodyClient({ baseURL: 'https://api.hoody.com', token: process.env.HOODY_TOKEN });await client.api.servers.listRegions();GET /api/v1/auth/config
Section titled “GET /api/v1/auth/config”Returns the public sign-in configuration (such as which identity providers are enabled) used to drive the OAuth flow.
This endpoint takes no parameters.
Response
Section titled “Response”{ "statusCode": 200, "data": { "appName": "Hoody", "logoUrl": "https://app.hoody.com/logo.svg", "primaryColor": "#5B6CFF", "githubEnabled": true, "googleEnabled": false, "signupEnabled": true, "inviteRequired": false }}curl https://api.hoody.com/api/v1/auth/configimport { HoodyClient } from 'hoody-sdk';
const client = new HoodyClient({ baseURL: 'https://api.hoody.com', token: process.env.HOODY_TOKEN });await client.api.auth.getConfig();PKCE OAuth (first-party web)
Section titled “PKCE OAuth (first-party web)”POST /api/v1/auth/authorize
Section titled “POST /api/v1/auth/authorize”Redeems the auth_intent_token from an intent-mode login (sent as Authorization: Bearer {auth_intent_token}, not a session token) for a single-use authorization code bound to the PKCE code_challenge and redirect_uri. Exchange the code at POST /api/v1/auth/exchange. SDK callers: the generated method sends the client’s configured token and has no per-call Authorization option, so call it on a client whose token is the auth_intent_token; a session token here is refused with 401.
Request Body
Section titled “Request Body”| Field | Type | Required | Description |
|---|---|---|---|
code_challenge | string | Yes | PKCE code_challenge (base64url SHA-256 of code_verifier), exactly 43 characters |
redirect_uri | string | Yes | HTTPS URL to redirect to after OAuth completes (must be on an allowed domain) |
curl -X POST https://api.hoody.com/api/v1/auth/authorize \ -H 'Content-Type: application/json' \ -d '{ "code_challenge": "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", "redirect_uri": "https://app.example.com/auth/callback" }'import { HoodyClient } from 'hoody-sdk';
const client = new HoodyClient({ baseURL: 'https://api.hoody.com', token: process.env.HOODY_TOKEN });await client.api.auth.oauth.authorize({ code_challenge: 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM', redirect_uri: 'https://app.example.com/auth/callback'});Response
Section titled “Response”{ "statusCode": 200, "data": { "code": "9f2c4e1a7b3d5f6081a2c3e4f5061728394a5b6c7d8e9f00112233445566778" }}code is a single-use authorization code (64 hex characters, valid 60 seconds). Redeem it at POST /api/v1/auth/exchange with the code_verifier and the same redirect_uri.
{ "statusCode": 401, "error": "MISSING_TOKEN", "message": "Authentication token required"}| Error Code | Title | Description | Resolution |
|---|---|---|---|
MISSING_TOKEN | Authentication token missing | No authentication token was provided in the request | Include a valid JWT token in the Authorization header as Bearer <token> |
INVALID_TOKEN | Invalid authentication token | The provided authentication token is malformed or invalid | Obtain a new token by logging in again or using a valid auth token |
TOKEN_EXPIRED | Authentication token expired | The provided authentication token has expired | Obtain a new token by logging in again or refreshing your session |
AUTH_TOKEN_LINEAGE_REVOKED | Parent auth token no longer valid | The auth token was created by another auth token, and that token (or one above it) has been revoked, disabled or has expired, so this one no longer authenticates | Create a new auth token from a valid one, or sign in again |
INVALID_CREDENTIALS | Invalid credentials | The provided username/email or password is incorrect | Verify your credentials and try again, or use the password reset feature |
EMAIL_NOT_VERIFIED | Email not verified | Returned by the login endpoint when the password is correct but the account’s email address has not been verified yet. It is only returned after the password has been checked. The response carries data.email so the client can offer a ‘resend verification email’ action without asking again. | Complete email verification by clicking the link sent to your inbox, or call /auth/resend-verification to receive a new link, or complete a password reset which also implicitly verifies the email. |
INVALID_OTP_CODE | Invalid OTP code | The provided 2FA code is incorrect or has expired | Generate a new code from your authenticator app and try again |
{ "statusCode": 403, "error": "INSUFFICIENT_PERMISSIONS", "message": "Insufficient permissions"}| Error Code | Title | Description | Resolution |
|---|---|---|---|
INSUFFICIENT_PERMISSIONS | Insufficient permissions | You do not have the required permissions to perform this action | Ask the resource owner to grant you access |
ACCOUNT_BANNED | Account banned | Your account has been banned and cannot access this resource | Contact support for information about your account status |
Validation Error: the request does not match the operation schema, or its JSON body cannot be parsed. message names each failing check, and data, when present, lists them one entry per failing field.
{ "statusCode": 422, "error": "Validation Error", "message": "Validation failed: /name must NOT have fewer than 3 characters"}POST /api/v1/auth/exchange
Section titled “POST /api/v1/auth/exchange”Completes the PKCE authorization-code flow by exchanging an authorization code and its code verifier for authentication tokens.
Request Body
Section titled “Request Body”| Field | Type | Required | Description |
|---|---|---|---|
code | string | Yes | Authorization code returned on the redirect (64 hex characters) |
code_verifier | string | Yes | PKCE code verifier used to compute the original code_challenge |
redirect_uri | string | Yes | HTTPS URL that must match the value sent to /auth/authorize |
curl -X POST https://api.hoody.com/api/v1/auth/exchange \ -H 'Content-Type: application/json' \ -d '{ "code": "4f3e2a1b9c8d7e6f5a4b3c2d1e0f9a8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f", "code_verifier": "qPsKJFJb5K9XqLkVz4a3bYpQvWjMnRtHuGxZeDcVbAo", "redirect_uri": "https://app.example.com/auth/callback" }'import { HoodyClient } from 'hoody-sdk';
const client = new HoodyClient({ baseURL: 'https://api.hoody.com', token: process.env.HOODY_TOKEN });await client.api.auth.oauth.exchange({ code: '4f3e2a1b9c8d7e6f5a4b3c2d1e0f9a8b7c6d5e4f3a2b1c0d9e8f7a6b5c4d3e2f', code_verifier: 'qPsKJFJb5K9XqLkVz4a3bYpQvWjMnRtHuGxZeDcVbAo', redirect_uri: 'https://app.example.com/auth/callback'});Response
Section titled “Response”{ "statusCode": 200, "message": "Login successful", "data": { "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", "expires_at": "2025-11-28T20:19:00.000Z", "expires_in": 86400, "refresh_expires_at": "2025-12-04T20:19:00.000Z", "refresh_expires_in": 604800, "identity_claim": { "kid": "v1", "payload_b64": "eyJjbGFpbV90eXBlIjoiaWRlbnRpdHkiLCJpc3MiOiJob29keS1hcGkiLCJzdWIiOiI1MDdmMWY3N2JjZjg2Y2Q3OTk0MzkwMTEiLCJ1c2VybmFtZSI6ImpvaG5fZG9lIiwidHlwZSI6InVzZXIiLCJpYXQiOjE3NDEyOTAwMDAsImV4cCI6MTc0Mzg4MjAwMCwia2lkIjoidjEifQ", "signature_hex": "abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef12" }, "user": { "id": "507f1f77bcf86cd799439011", "username": "john_doe", "alias": "John Doe", "email": "john.doe@example.com", "is_banned": false, "email_verified": true, "avatar_url": null, "signup_method": "email", "created_at": "2024-12-01T10:00:00.000Z", "updated_at": "2025-01-15T10:30:00.000Z" }, "server": { "id": "507f1f77bcf86cd799439099", "name": "node-example-1", "country": "US", "region": "us-east", "city": "New York", "datacenter": "nyc1", "is_ready": true }, "project": { "id": "507f1f77bcf86cd799439020", "alias": "default" }, "container": { "id": "507f1f77bcf86cd799439030", "name": "alpha" } }}{ "statusCode": 403, "error": "ACCOUNT_BANNED", "message": "Your account is banned"}| Error Code | Title | Description | Resolution |
|---|---|---|---|
ACCOUNT_BANNED | Account banned | Your account has been banned and cannot access this resource | Contact support for information about your account status |
Validation Error: the request does not match the operation schema, or its JSON body cannot be parsed. message names each failing check, and data, when present, lists them one entry per failing field.
{ "statusCode": 422, "error": "Validation Error", "message": "Validation failed: /name must NOT have fewer than 3 characters"}Provider OAuth (GitHub, Google)
Section titled “Provider OAuth (GitHub, Google)”GET /api/v1/auth/github
Section titled “GET /api/v1/auth/github”Redirects the browser to GitHub for OAuth authentication. Browser-only endpoint.
Parameters
Section titled “Parameters”| Name | In | Type | Required | Description |
|---|---|---|---|---|
client | query | string | No | Optional client-declared source channel for analytics: web, ssh, webssh, cli, sdk, or agent. Unrecognised values are recorded as unknown. |
intent | query | string | No | OAuth intent: login (default). star_check is still accepted and behaves like login. |
redirect_uri | query | string | Yes | Frontend URL to redirect to after OAuth completes (must be on an allowed domain). |
code_challenge | query | string | Yes | PKCE code_challenge (base64url SHA-256 of code_verifier). Required, all OAuth flows must use PKCE post-migration. |
invite_code | query | string | No | Optional invite code (coupon) captured from the signup link. Normalized and hashed at redirect time; only the hash travels in the OAuth state, never the raw code. Memorized hash-only on a NEW account and applied automatically; not validated here. |
Response
Section titled “Response”HTTP/1.1 302 FoundLocation: https://github.com/login/oauth/authorize?...Validation Error: the request does not match the operation schema, or its JSON body cannot be parsed. message names each failing check, and data, when present, lists them one entry per failing field.
{ "statusCode": 422, "error": "Validation Error", "message": "Validation failed: /name must NOT have fewer than 3 characters"}curl -i 'https://api.hoody.com/api/v1/auth/github?redirect_uri=https%3A%2F%2Fapp.example.com%2Fauth%2Fcallback&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM'GET /api/v1/auth/github/callback
Section titled “GET /api/v1/auth/github/callback”Handles the GitHub OAuth callback. Browser-only endpoint.
Parameters
Section titled “Parameters”| Name | In | Type | Required | Description |
|---|---|---|---|---|
code | query | string | No | Authorization code returned by GitHub. |
state | query | string | Yes | Signed state JWT issued by /auth/github. |
error | query | string | No | Provider-side failure code (e.g. access_denied). Present instead of code when the user declines. |
error_description | query | string | No | Provider-supplied human-readable failure description. |
error_uri | query | string | No | Provider-supplied failure reference URL. |
Response
Section titled “Response”HTTP/1.1 302 FoundLocation: https://app.example.com/auth/callback?...Validation Error: the request does not match the operation schema, or its JSON body cannot be parsed. message names each failing check, and data, when present, lists them one entry per failing field.
{ "statusCode": 422, "error": "Validation Error", "message": "Validation failed: /name must NOT have fewer than 3 characters"}curl -i 'https://api.hoody.com/api/v1/auth/github/callback?code=acbd1234&state=<state-jwt>'GET /api/v1/auth/google
Section titled “GET /api/v1/auth/google”Redirects the browser to Google for OAuth authentication. Browser-only endpoint.
Parameters
Section titled “Parameters”| Name | In | Type | Required | Description |
|---|---|---|---|---|
client | query | string | No | Optional client-declared source channel for analytics: web, ssh, webssh, cli, sdk, or agent. Unrecognised values are recorded as unknown. |
redirect_uri | query | string | Yes | Frontend URL to redirect to after OAuth completes (must be on an allowed domain). |
code_challenge | query | string | Yes | PKCE code_challenge (base64url SHA-256 of code_verifier). Required, all OAuth flows must use PKCE post-migration. |
invite_code | query | string | No | Optional invite code (coupon) captured from the signup link. Normalized and hashed at redirect time; only the hash travels in the OAuth state, never the raw code. Memorized hash-only on a NEW account and applied automatically; not validated here. |
Response
Section titled “Response”HTTP/1.1 302 FoundLocation: https://accounts.google.com/o/oauth2/v2/auth?...Validation Error: the request does not match the operation schema, or its JSON body cannot be parsed. message names each failing check, and data, when present, lists them one entry per failing field.
{ "statusCode": 422, "error": "Validation Error", "message": "Validation failed: /name must NOT have fewer than 3 characters"}curl -i 'https://api.hoody.com/api/v1/auth/google?redirect_uri=https%3A%2F%2Fapp.example.com%2Fauth%2Fcallback&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM'GET /api/v1/auth/google/callback
Section titled “GET /api/v1/auth/google/callback”Handles the Google OAuth callback. Browser-only endpoint.
Parameters
Section titled “Parameters”| Name | In | Type | Required | Description |
|---|---|---|---|---|
code | query | string | No | Authorization code returned by Google. |
state | query | string | Yes | Signed state JWT issued by /auth/google. |
error | query | string | No | Provider-side failure code (e.g. access_denied). Present instead of code when the user declines. |
error_description | query | string | No | Provider-supplied human-readable failure description. |
error_uri | query | string | No | Provider-supplied failure reference URL. |
Response
Section titled “Response”HTTP/1.1 302 FoundLocation: https://app.example.com/auth/callback?...Validation Error: the request does not match the operation schema, or its JSON body cannot be parsed. message names each failing check, and data, when present, lists them one entry per failing field.
{ "statusCode": 422, "error": "Validation Error", "message": "Validation failed: /name must NOT have fewer than 3 characters"}curl -i 'https://api.hoody.com/api/v1/auth/google/callback?code=acbd1234&state=<state-jwt>'Device flow & popup handoff
Section titled “Device flow & popup handoff”GET /api/v1/auth/device/authorize
Section titled “GET /api/v1/auth/device/authorize”GET endpoint the verification page navigates to. Verifies the provider is fully configured (302 back to the device page with ?error=provider_unavailable, ticket intact, when not), then consumes the device_verify_ticket + __Host-device_verify cookie atomically and redirects to the provider with a server-injected device_binding + attempt nonce. Sets Referrer-Policy: no-referrer.
Parameters
Section titled “Parameters”| Name | In | Type | Required | Description |
|---|---|---|---|---|
ticket | query | string | Yes | device_verify_ticket from /device/verify_code. |
provider | query | string | Yes | OAuth provider. One of github or google. |
Response
Section titled “Response”HTTP/1.1 302 FoundLocation: https://github.com/login/oauth/authorize?...{ "statusCode": 403, "error": "Forbidden", "message": "Missing or invalid device verification cookie"}{ "statusCode": 410, "error": "Gone", "message": "Device verification ticket is no longer valid"}Validation Error: the request does not match the operation schema, or its JSON body cannot be parsed. message names each failing check, and data, when present, lists them one entry per failing field.
{ "statusCode": 422, "error": "Validation Error", "message": "Validation failed: /name must NOT have fewer than 3 characters"}curl -i 'https://api.hoody.com/api/v1/auth/device/authorize?ticket=<device_verify_ticket>&provider=github'POST /api/v1/auth/device/code
Section titled “POST /api/v1/auth/device/code”Issues a device_code (polled by the CLI) and a short hand-typeable user_code (shown to the human). Public, no auth. RFC-8628-inspired but deliberately not a standards-compliant device grant: no client_id/grant_type, lifecycle errors are nested under data, and the poll interval is a fixed 5 s after slow_down.
Request Body
Section titled “Request Body”| Field | Type | Required | Description |
|---|---|---|---|
client_name | string | No | Shown on the verification page as “X is requesting access”. |
client | string | No | Optional client-declared source channel for analytics: web, ssh, webssh, cli, sdk, or agent. Unrecognised values are recorded as unknown; never affects authentication. |
code_challenge | string | No | Optional PKCE on the device flow itself; if present the poll REQUIRES the verifier. |
curl -X POST https://api.hoody.com/api/v1/auth/device/code \ -H 'Content-Type: application/json' \ -d '{ "client_name": "Hoody CLI", "client": "cli", "code_challenge": "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" }'import { HoodyClient } from 'hoody-sdk';
const client = new HoodyClient({ baseURL: 'https://api.hoody.com', token: process.env.HOODY_TOKEN });await client.api.auth.device.start({ client_name: 'Hoody CLI', client: 'cli', code_challenge: 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM'});Response
Section titled “Response”{ "statusCode": 200, "data": { "device_code": "7c2b4e8a9d1f3c5b7e9a2c4d6f8b1a3c5e7d9f2b4a6c8e1d3f5b7a9c2e4d6f8b1", "user_code": "ABCD-1234", "verification_uri": "https://api.hoody.com/auth/device", "verification_uri_complete": "https://api.hoody.com/auth/device?code=ABCD-1234", "interval": 5, "expires_in": 600 }}Validation Error: the request does not match the operation schema, or its JSON body cannot be parsed. message names each failing check, and data, when present, lists them one entry per failing field.
{ "statusCode": 422, "error": "Validation Error", "message": "Validation failed: /name must NOT have fewer than 3 characters"}POST /api/v1/auth/device/verify_code
Section titled “POST /api/v1/auth/device/verify_code”Page-only helper. On a live pending row, mints a one-time device_verify_ticket and sets the __Host-device_verify cookie. Leaks only client_name and coarse status.
Request Body
Section titled “Request Body”| Field | Type | Required | Description |
|---|---|---|---|
user_code | string | Yes | XXXX-XXXX user code (dashes optional). |
curl -X POST https://api.hoody.com/api/v1/auth/device/verify_code \ -H 'Content-Type: application/json' \ -d '{ "user_code": "ABCD-1234" }'import { HoodyClient } from 'hoody-sdk';
const client = new HoodyClient({ baseURL: 'https://api.hoody.com', token: process.env.HOODY_TOKEN });await client.api.auth.device.verifyCode({ user_code: 'ABCD-1234' });Response
Section titled “Response”{ "statusCode": 200, "data": { "status": "pending", "client_name": "Hoody CLI", "expires_in": 300, "device_verify_ticket": "5b8e2a1c4d7f9a3b6c8e1d2f4a6b8c0e2d4f6a8b1c3e5d7f9a1b3c5e7d9f1a3b" }}status is "pending" while the code is still live. When the code has already been approved, status is "approved" and the other fields are absent.
{ "statusCode": 404, "error": "Not Found", "message": "Unknown code"}No device request has this user code (message Unknown code: ask the user to re-type it), or the device flow is not enabled.
{ "statusCode": 410, "error": "Gone", "message": "This code is no longer valid"}The code expired or was denied. Start the device login again for a new code.
{ "statusCode": 422, "error": "Validation Error", "message": "Validation failed: /user_code must NOT have more than 16 characters"}| Error Code | Title | Description | Resolution |
|---|---|---|---|
VALIDATION_ERROR | Request validation failed | The request body failed schema validation: user_code is missing or longer than 16 characters. | Send the XXXX-XXXX code the device displayed. |
{ "statusCode": 429, "error": "Too Many Requests", "message": "Rate limit exceeded for this endpoint. Please try again later."}| Error Code | Title | Description | Resolution |
|---|---|---|---|
RATE_LIMIT_EXCEEDED | Too many verification attempts | This address sent too many user-code checks in the last hour, or the rate limiter could not be reached. | Wait a few minutes and try the code again. |
POST /api/v1/auth/device/login
Section titled “POST /api/v1/auth/device/login”Password sign-in for the device authorize step (cookie + ticket gated). Page-only helper. Verifies email/username + password with FULL login parity (shared per-account throttle, timing-normalized bcrypt) behind the device_verify_ticket + __Host-device_verify cookie gate. NEVER returns session tokens: no-2FA → {status:"approved"} (tokens mint only at /device/token); 2FA → {requires_2fa,temp_token} (device-bound partial, no code_challenge). Credential failures do NOT consume the ticket. Feature-flag off → 404; schema-invalid body → 422.
Request Body
Section titled “Request Body”| Field | Type | Required | Description |
|---|---|---|---|
ticket | string | Yes | device_verify_ticket from /device/verify_code. |
username | string | No | Username (alternative to email). |
email | string | No | Email address (alternative to username). |
password | string | Yes | Account password. |
curl -X POST https://api.hoody.com/api/v1/auth/device/login \ -H 'Content-Type: application/json' \ -b '__Host-device_verify=<device_verify_cookie>' \ -d '{ "ticket": "5b8e2a1c4d7f9a3b6c8e1d2f4a6b8c0e2d4f6a8b1c3e5d7f9a1b3c5e7d9f1a3b", "username": "john_doe", "password": "SecurePassword123!" }'import { HoodyClient } from 'hoody-sdk';
const client = new HoodyClient({ baseURL: 'https://api.hoody.com', token: process.env.HOODY_TOKEN });await client.api.auth.device.login({ ticket: '<device_verify_ticket>', username: 'john_doe', password: 'SecurePassword123!'});Response
Section titled “Response”{ "statusCode": 200, "data": { "status": "approved" }}{ "statusCode": 401, "error": "INVALID_CREDENTIALS", "message": "Invalid username or password"}| Error Code | Title | Description | Resolution |
|---|---|---|---|
INVALID_CREDENTIALS | Invalid credentials | The provided username/email or password is incorrect | Verify your credentials and try again, or use the password reset feature |
EMAIL_NOT_VERIFIED | Email not verified | Returned when the password is correct but the account’s email address has not been verified yet | Complete email verification, or call /auth/resend-verification, or complete a password reset which also implicitly verifies the email |
{ "statusCode": 403, "error": "ACCOUNT_BANNED", "message": "Your account is banned"}| Error Code | Title | Description | Resolution |
|---|---|---|---|
ACCOUNT_BANNED | Account banned | Your account has been banned and cannot access this resource | Contact support for information about your account status |
{ "statusCode": 404, "error": "Not Found", "message": "Device flow feature is not enabled on this instance"}{ "statusCode": 410, "error": "Gone", "message": "Device verification ticket is no longer valid"}{ "statusCode": 422, "error": "VALIDATION_ERROR", "message": "Validation failed: /ticket must match pattern \"^[0-9a-f]{64}$\""}| Error Code | Title | Description | Resolution |
|---|---|---|---|
VALIDATION_ERROR | Request validation failed | The request body failed schema validation (e.g. a malformed ticket; for /device/login also a missing identifier or out-of-bounds field lengths) | Correct the request fields and retry |
{ "statusCode": 429, "error": "RATE_LIMIT_EXCEEDED", "message": "Too many requests"}| Error Code | Title | Description | Resolution |
|---|---|---|---|
RATE_LIMIT_EXCEEDED | Rate limit exceeded | You have exceeded the rate limit for this endpoint | Wait before making additional requests, or upgrade your plan for higher limits |
POST /api/v1/auth/device/deny
Section titled “POST /api/v1/auth/device/deny”Page-only helper of the RFC-8628-inspired device flow. Cookie + ticket gated, no credentials required, possession of the live ticket + cookie is the refusing authority. Flips the pending row to denied; the terminal poll then reports access_denied. Once a row has been approved it can never be un-approved.
Request Body
Section titled “Request Body”| Field | Type | Required | Description |
|---|---|---|---|
ticket | string | Yes | device_verify_ticket from /device/verify_code. |
curl -X POST https://api.hoody.com/api/v1/auth/device/deny \ -H 'Content-Type: application/json' \ -b '__Host-device_verify=<device_verify_cookie>' \ -d '{ "ticket": "5b8e2a1c4d7f9a3b6c8e1d2f4a6b8c0e2d4f6a8b1c3e5d7f9a1b3c5e7d9f1a3b" }'import { HoodyClient } from 'hoody-sdk';
const client = new HoodyClient({ baseURL: 'https://api.hoody.com', token: process.env.HOODY_TOKEN });await client.api.auth.device.deny({ ticket: '<device_verify_ticket>' });Response
Section titled “Response”{ "statusCode": 200, "data": { "status": "denied" }}{ "statusCode": 403, "error": "Forbidden", "message": "Missing or invalid device verification cookie"}{ "statusCode": 404, "error": "Not Found", "message": "Device flow feature is not enabled on this instance"}{ "statusCode": 410, "error": "Gone", "message": "Device verification ticket is no longer valid"}{ "statusCode": 422, "error": "VALIDATION_ERROR", "message": "Validation failed: /ticket must match pattern \"^[0-9a-f]{64}$\""}| Error Code | Title | Description | Resolution |
|---|---|---|---|
VALIDATION_ERROR | Request validation failed | The request body failed schema validation | Correct the request fields and retry |
{ "statusCode": 429, "error": "RATE_LIMIT_EXCEEDED", "message": "Too many requests"}| Error Code | Title | Description | Resolution |
|---|---|---|---|
RATE_LIMIT_EXCEEDED | Rate limit exceeded | You have exceeded the rate limit for this endpoint | Wait before making additional requests, or upgrade your plan for higher limits |
POST /api/v1/auth/device/token
Section titled “POST /api/v1/auth/device/token”Polled by the CLI while the user completes the browser step. 400 + {data:{error}} for lifecycle states (authorization_pending|slow_down|access_denied|expired_token), 200 + token-set on approval (single-use; also requires the approving user’s session generation to still be current: a password reset / logout-all after approval yields expired_token), 429 on the outer flood guard. Public, no auth. Lifecycle errors are nested under data, unlike RFC 8628 §3.5.
Request Body
Section titled “Request Body”| Field | Type | Required | Description |
|---|---|---|---|
device_code | string | Yes | The device_code returned by /device/code. |
code_verifier | string | No | PKCE code_verifier. Required if code_challenge was set when the code was issued. |
curl -X POST https://api.hoody.com/api/v1/auth/device/token \ -H 'Content-Type: application/json' \ -d '{ "device_code": "7c2b4e8a9d1f3c5b7e9a2c4d6f8b1a3c5e7d9f2b4a6c8e1d3f5b7a9c2e4d6f8b1", "code_verifier": "qPsKJFJb5K9XqLkVz4a3bYpQvWjMnRtHuGxZeDcVbAo" }'import { HoodyClient } from 'hoody-sdk';
const client = new HoodyClient({ baseURL: 'https://api.hoody.com', token: process.env.HOODY_TOKEN });await client.api.auth.device.poll({ device_code: '7c2b4e8a9d1f3c5b7e9a2c4d6f8b1a3c5e7d9f2b4a6c8e1d3f5b7a9c2e4d6f8b1', code_verifier: 'qPsKJFJb5K9XqLkVz4a3bYpQvWjMnRtHuGxZeDcVbAo'});Response
Section titled “Response”{ "statusCode": 200, "data": { "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", "expires_at": "2025-11-28T20:19:00.000Z", "expires_in": 86400, "refresh_expires_at": "2025-12-04T20:19:00.000Z", "refresh_expires_in": 604800, "identity_claim": { "kid": "v1", "payload_b64": "eyJjbGFpbV90eXBlIjoiaWRlbnRpdHkiLCJpc3MiOiJob29keS1hcGkiLCJzdWIiOiI1MDdmMWY3N2JjZjg2Y2Q3OTk0MzkwMTEiLCJ1c2VybmFtZSI6ImpvaG5fZG9lIiwidHlwZSI6InVzZXIiLCJpYXQiOjE3NDEyOTAwMDAsImV4cCI6MTc0Mzg4MjAwMCwia2lkIjoidjEifQ", "signature_hex": "abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef12" }, "user": { "id": "507f1f77bcf86cd799439011", "username": "john_doe", "alias": "John Doe", "email": "john.doe@example.com", "email_verified": true, "signup_method": "email", "avatar_url": null, "created_at": "2024-12-01T10:00:00.000Z", "updated_at": "2025-01-15T10:30:00.000Z" }, "server": { "id": "507f1f77bcf86cd799439099", "name": "node-example-1", "country": "US", "region": "us-east", "city": "New York", "datacenter": "nyc1", "is_ready": true }, "project": { "id": "507f1f77bcf86cd799439020", "alias": "default" }, "container": { "id": "507f1f77bcf86cd799439030", "name": "alpha", "status": "running" } }}{ "statusCode": 400, "data": { "error": "authorization_pending" }}The data.error field carries the lifecycle state: authorization_pending, slow_down, access_denied, or expired_token.
Validation Error: the request does not match the operation schema, or its JSON body cannot be parsed. message names each failing check, and data, when present, lists them one entry per failing field.
{ "statusCode": 422, "error": "Validation Error", "message": "Validation failed: /name must NOT have fewer than 3 characters"}POST /api/v1/auth/launch/initiate
Section titled “POST /api/v1/auth/launch/initiate”POST endpoint that issues a one-shot launch ticket bound to the request Origin header. The frontend navigates the popup to the returned launch_url, which consumes the ticket and runs the existing PKCE-protected OAuth flow with state_id + opener_origin plumbed through.
Request Body
Section titled “Request Body”| Field | Type | Required | Description |
|---|---|---|---|
provider | string | Yes | OAuth provider. One of github or google. |
client | string | No | Optional client-declared source channel for analytics. |
code_challenge | string | Yes | PKCE code_challenge (base64url SHA-256 of code_verifier, exactly 43 chars). |
state_id | string | Yes | Per-attempt UUID v4, plumbed through state JWT, cookie name, fragment, message filter. |
curl -X POST https://api.hoody.com/api/v1/auth/launch/initiate \ -H 'Content-Type: application/json' \ -H 'Origin: https://app.example.com' \ -d '{ "provider": "github", "code_challenge": "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", "state_id": "0d3b7c20-4e2d-4b9a-8f01-2a3b4c5d6e7f" }'import { HoodyClient } from 'hoody-sdk';
const client = new HoodyClient({ baseURL: 'https://api.hoody.com', token: process.env.HOODY_TOKEN });await client.api.auth.oauth.startLaunch({ provider: 'github', code_challenge: 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM', state_id: '0d3b7c20-4e2d-4b9a-8f01-2a3b4c5d6e7f'});Response
Section titled “Response”{ "statusCode": 200, "data": { "launch_url": "https://api.hoody.com/api/v1/auth/launch/start?ticket=8a4f2b6c1d3e5f7a9b1c3d5e7f9a1b3c" }}Validation Error: the request does not match the operation schema, or its JSON body cannot be parsed. message names each failing check, and data, when present, lists them one entry per failing field.
{ "statusCode": 422, "error": "Validation Error", "message": "Validation failed: /name must NOT have fewer than 3 characters"}GET /api/v1/auth/launch/start
Section titled “GET /api/v1/auth/launch/start”GET endpoint the popup navigates to. Consumes the launch ticket atomically and runs the existing OAuth redirect flow. Sets Referrer-Policy: no-referrer.
Parameters
Section titled “Parameters”| Name | In | Type | Required | Description |
|---|---|---|---|---|
ticket | query | string | Yes | One-shot ticket from /launch/initiate response. |
Response
Section titled “Response”HTTP/1.1 302 FoundLocation: https://github.com/login/oauth/authorize?...{ "statusCode": 410, "error": "Gone", "message": "Launch ticket is no longer valid"}Validation Error: the request does not match the operation schema, or its JSON body cannot be parsed. message names each failing check, and data, when present, lists them one entry per failing field.
{ "statusCode": 422, "error": "Validation Error", "message": "Validation failed: /name must NOT have fewer than 3 characters"}curl -i 'https://api.hoody.com/api/v1/auth/launch/start?ticket=<launch_ticket>'POST /api/v1/auth/intent/cancel
Section titled “POST /api/v1/auth/intent/cancel”POST endpoint with Authorization: Bearer <intent or temp_token>. Idempotent. Used by the handoff page when the user dismisses the confirmation.
This endpoint takes no parameters and no request body.
Response
Section titled “Response”The 204 response has no body: the cancellation is confirmed by the status code alone.
Validation Error: the request does not match the operation schema, or its JSON body cannot be parsed. message names each failing check, and data, when present, lists them one entry per failing field.
{ "statusCode": 422, "error": "Validation Error", "message": "Validation failed: /name must NOT have fewer than 3 characters"}curl -X POST https://api.hoody.com/api/v1/auth/intent/cancel \ -H 'Authorization: Bearer <intent_or_temp_token>'import { HoodyClient } from 'hoody-sdk';
const client = new HoodyClient({ baseURL: 'https://api.hoody.com', token: process.env.HOODY_TOKEN });await client.api.auth.oauth.cancelIntent();Account lifecycle
Section titled “Account lifecycle”POST /api/v1/auth/signup
Section titled “POST /api/v1/auth/signup”Create a new account with email and password. A verification email will be sent. The account is not active until the email is verified.
Request Body
Section titled “Request Body”| Field | Type | Required | Description |
|---|---|---|---|
email | string | Yes | Email address for the new account. |
password | string | Yes | Password (min 12 chars, at most 72 UTF-8 bytes, at least 3 of the 4 character classes: uppercase, lowercase, number, special). |
region | string | No | Optional preferred server region (e.g. eu-west). If omitted, auto-assigned by GeoIP proximity. |
invite_code | string | No | Optional invite code (coupon) captured from the signup link. Memorized (hash-only) and applied automatically after email verification; not validated at signup. |
client | string | No | Optional client-declared source channel for analytics. |
curl -X POST https://api.hoody.com/api/v1/auth/signup \ -H 'Content-Type: application/json' \ -d '{ "email": "john.doe@example.com", "password": "SecurePassword123!", "region": "eu-west", "invite_code": "WELCOME2026" }'import { HoodyClient } from 'hoody-sdk';
const client = new HoodyClient({ baseURL: 'https://api.hoody.com', token: process.env.HOODY_TOKEN });await client.api.auth.signup({ email: 'john.doe@example.com', password: 'SecurePassword123!', region: 'eu-west', invite_code: 'WELCOME2026'});Response
Section titled “Response”{ "statusCode": 200, "message": "Account created. Check your email to verify.", "data": { "email": "john.doe@example.com" }}{ "statusCode": 400, "message": "Password does not meet complexity requirements"}{ "statusCode": 403, "message": "Signups are currently disabled"}Validation Error: the request does not match the operation schema, or its JSON body cannot be parsed. message names each failing check, and data, when present, lists them one entry per failing field.
{ "statusCode": 422, "error": "Validation Error", "message": "Validation failed: /name must NOT have fewer than 3 characters"}POST /api/v1/auth/verify-email
Section titled “POST /api/v1/auth/verify-email”Verify the email address using the token from the verification email. Default response returns full login credentials. When response_mode=intent + code_challenge are provided, returns an opaque auth_intent_token for PKCE exchange (hosted auth UI flow). If 2FA is enabled on the account, returns requires_2fa + temp_token instead. See Identity claims for the format of the returned identity_claim.
Request Body
Section titled “Request Body”| Field | Type | Required | Description |
|---|---|---|---|
client | string | No | Optional client-declared source channel for analytics. |
token | string | Yes | Verification token from the email link (64 hex chars). |
response_mode | string | No | Response shape. tokens (default) returns access/refresh tokens. intent returns an opaque auth_intent_token for PKCE exchange. |
code_challenge | string | No | PKCE code_challenge. Required when response_mode=intent. |
curl -X POST https://api.hoody.com/api/v1/auth/verify-email \ -H 'Content-Type: application/json' \ -d '{ "token": "9a4f2b6c1d3e5f7a9b1c3d5e7f9a1b3c5e7d9f2b4a6c8e1d3f5b7a9c2e4d6f8b1" }'import { HoodyClient } from 'hoody-sdk';
const client = new HoodyClient({ baseURL: 'https://api.hoody.com', token: process.env.HOODY_TOKEN });await client.api.auth.verifyEmail({ token: '<verification_token>' });Response
Section titled “Response”{ "statusCode": 200, "message": "Email verified", "data": { "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", "expires_at": "2025-11-28T20:19:00.000Z", "expires_in": 86400, "refresh_expires_at": "2025-12-04T20:19:00.000Z", "refresh_expires_in": 604800, "identity_claim": { "kid": "v1", "payload_b64": "eyJjbGFpbV90eXBlIjoiaWRlbnRpdHkiLCJpc3MiOiJob29keS1hcGkiLCJzdWIiOiI1MDdmMWY3N2JjZjg2Y2Q3OTk0MzkwMTEiLCJ1c2VybmFtZSI6ImpvaG5fZG9lIiwidHlwZSI6InVzZXIiLCJpYXQiOjE3NDEyOTAwMDAsImV4cCI6MTc0Mzg4MjAwMCwia2lkIjoidjEifQ", "signature_hex": "abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef12" }, "user": { "id": "507f1f77bcf86cd799439011", "username": "john_doe", "alias": "John Doe", "email": "john.doe@example.com", "email_verified": true, "signup_method": "email", "avatar_url": null, "created_at": "2024-12-01T10:00:00.000Z", "updated_at": "2025-01-15T10:30:00.000Z" }, "server": { "id": "507f1f77bcf86cd799439099", "name": "node-example-1", "country": "US", "region": "us-east", "city": "New York", "datacenter": "nyc1", "is_ready": true }, "project": { "id": "507f1f77bcf86cd799439020", "alias": "default" }, "container": { "id": "507f1f77bcf86cd799439030", "name": "alpha" } }}{ "statusCode": 400, "message": "Invalid or expired verification token"}{ "statusCode": 403, "error": "ACCOUNT_BANNED", "message": "Your account is banned"}| Error Code | Title | Description | Resolution |
|---|---|---|---|
ACCOUNT_BANNED | Account banned | Your account has been banned and cannot access this resource | Contact support for information about your account status |
Validation Error: the request does not match the operation schema, or its JSON body cannot be parsed. message names each failing check, and data, when present, lists them one entry per failing field.
{ "statusCode": 422, "error": "Validation Error", "message": "Validation failed: /name must NOT have fewer than 3 characters"}POST /api/v1/auth/forgot-password
Section titled “POST /api/v1/auth/forgot-password”Send a password reset email. Always returns success to prevent email enumeration.
Request Body
Section titled “Request Body”| Field | Type | Required | Description |
|---|---|---|---|
email | string | Yes | Email address associated with the account. |
curl -X POST https://api.hoody.com/api/v1/auth/forgot-password \ -H 'Content-Type: application/json' \ -d '{ "email": "john.doe@example.com" }'import { HoodyClient } from 'hoody-sdk';
const client = new HoodyClient({ baseURL: 'https://api.hoody.com', token: process.env.HOODY_TOKEN });await client.api.auth.recoverPassword({ email: 'john.doe@example.com' });Response
Section titled “Response”{ "statusCode": 200, "message": "If an account exists for that email, a password reset link has been sent."}Validation Error: the request does not match the operation schema, or its JSON body cannot be parsed. message names each failing check, and data, when present, lists them one entry per failing field.
{ "statusCode": 422, "error": "Validation Error", "message": "Validation failed: /name must NOT have fewer than 3 characters"}POST /api/v1/auth/reset-password
Section titled “POST /api/v1/auth/reset-password”Set a new password using the reset token from the password reset email.
Request Body
Section titled “Request Body”| Field | Type | Required | Description |
|---|---|---|---|
token | string | Yes | Password reset token from the email link (64 hex chars). |
password | string | Yes | New password (min 12 chars, at most 72 UTF-8 bytes). |
curl -X POST https://api.hoody.com/api/v1/auth/reset-password \ -H 'Content-Type: application/json' \ -d '{ "token": "9a4f2b6c1d3e5f7a9b1c3d5e7f9a1b3c5e7d9f2b4a6c8e1d3f5b7a9c2e4d6f8b1", "password": "NewSecurePassword123!" }'import { HoodyClient } from 'hoody-sdk';
const client = new HoodyClient({ baseURL: 'https://api.hoody.com', token: process.env.HOODY_TOKEN });await client.api.auth.resetPassword({ token: '<reset_token>', password: 'NewSecurePassword123!'});Response
Section titled “Response”{ "statusCode": 200, "message": "Password has been reset. You can now log in."}{ "statusCode": 400, "message": "Invalid or expired reset token"}Validation Error: the request does not match the operation schema, or its JSON body cannot be parsed. message names each failing check, and data, when present, lists them one entry per failing field.
{ "statusCode": 422, "error": "Validation Error", "message": "Validation failed: /name must NOT have fewer than 3 characters"}POST /api/v1/auth/resend-verification
Section titled “POST /api/v1/auth/resend-verification”Resend the email verification link. Always returns success to prevent email enumeration.
Request Body
Section titled “Request Body”| Field | Type | Required | Description |
|---|---|---|---|
email | string | Yes | Email address to resend verification to. |
curl -X POST https://api.hoody.com/api/v1/auth/resend-verification \ -H 'Content-Type: application/json' \ -d '{ "email": "john.doe@example.com" }'import { HoodyClient } from 'hoody-sdk';
const client = new HoodyClient({ baseURL: 'https://api.hoody.com', token: process.env.HOODY_TOKEN });await client.api.auth.sendVerificationEmail({ email: 'john.doe@example.com' });Response
Section titled “Response”{ "statusCode": 200, "message": "If the email is registered and unverified, a new verification link has been sent."}Validation Error: the request does not match the operation schema, or its JSON body cannot be parsed. message names each failing check, and data, when present, lists them one entry per failing field.
{ "statusCode": 422, "error": "Validation Error", "message": "Validation failed: /name must NOT have fewer than 3 characters"}Login & session management
Section titled “Login & session management”POST /api/v1/users/auth/login
Section titled “POST /api/v1/users/auth/login”Authenticate with username and password to receive a JWT access token (expires in 1 day) and refresh token (expires in 7 days). Use the access token in the Authorization header for subsequent requests: Authorization: Bearer {token}. If 2FA is enabled, the response carries requires_2fa + temp_token instead. Complete the flow via Verify 2FA code during login. On success, the response includes an identity claim under data.identity_claim proving “Hoody authenticated this user”.
Request Body
Section titled “Request Body”| Field | Type | Required | Description |
|---|---|---|---|
username | string | No | Username (alphanumeric, underscores, hyphens; alternative to email). |
email | string | No | Email address (alternative to username). |
password | string | Yes | Account password, checked against the stored credential. Login applies no strength rule of its own, so a password created under an earlier policy still works. |
response_mode | string | No | Response shape. tokens (default) returns access/refresh tokens. intent returns an opaque auth_intent_token for PKCE exchange (hosted auth UI only). |
client | string | No | Optional client-declared source channel for analytics. |
code_challenge | string | No | PKCE code_challenge. Required when response_mode=intent. |
curl -X POST https://api.hoody.com/api/v1/users/auth/login \ -H 'Content-Type: application/json' \ -d '{ "username": "john_doe", "password": "SecurePassword123!" }'import { HoodyClient } from 'hoody-sdk';
const client = new HoodyClient({ baseURL: 'https://api.hoody.com', token: process.env.HOODY_TOKEN });await client.api.auth.login({ username: 'john_doe', password: 'SecurePassword123!'});Response
Section titled “Response”{ "statusCode": 200, "message": "Login successful", "data": { "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", "expires_at": "2025-11-28T20:19:00.000Z", "expires_in": 86400, "refresh_expires_at": "2025-12-04T20:19:00.000Z", "refresh_expires_in": 604800, "identity_claim": { "kid": "v1", "payload_b64": "eyJjbGFpbV90eXBlIjoiaWRlbnRpdHkiLCJpc3MiOiJob29keS1hcGkiLCJzdWIiOiI1MDdmMWY3N2JjZjg2Y2Q3OTk0MzkwMTEiLCJ1c2VybmFtZSI6ImpvaG5fZG9lIiwidHlwZSI6InVzZXIiLCJpYXQiOjE3NDEyOTAwMDAsImV4cCI6MTc0Mzg4MjAwMCwia2lkIjoidjEifQ", "signature_hex": "abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef12" }, "client_ip": "192.168.1.100", "recent_login_ips": [ { "ip": "192.168.1.100", "timestamp": "2025-11-27T10:30:00.000Z" }, { "ip": "10.0.0.5", "timestamp": "2025-11-26T09:15:00.000Z" } ], "auth_token_count": 2, "user": { "id": "507f1f77bcf86cd799439011", "username": "john_doe", "alias": "John Doe", "email": "john.doe@example.com", "is_banned": false, "email_verified": true, "metadata": {}, "created_at": "2024-12-01T10:00:00.000Z", "updated_at": "2025-11-27T10:30:00.000Z" }, "server": { "id": "507f1f77bcf86cd799439099", "name": "node-example-1", "country": "US", "region": "us-east", "city": "New York", "datacenter": "nyc1", "is_ready": true }, "project": { "id": "507f1f77bcf86cd799439020", "alias": "default" }, "container": { "id": "507f1f77bcf86cd799439030", "name": "alpha" } }}{ "statusCode": 400, "error": "VALIDATION_ERROR", "message": "Validation failed: password is required"}| Error Code | Title | Description | Resolution |
|---|---|---|---|
VALIDATION_ERROR | Invalid input parameters | One or more request parameters failed validation | Check the error message for specific field requirements and correct your input |
MISSING_REQUIRED_FIELD | Required field missing | One or more required fields are missing from the request | Include all required fields as specified in the API documentation |
{ "statusCode": 401, "error": "INVALID_CREDENTIALS", "message": "Invalid username or password"}| Error Code | Title | Description | Resolution |
|---|---|---|---|
INVALID_CREDENTIALS | Invalid credentials | The provided username/email or password is incorrect | Verify your credentials and try again, or use the password reset feature |
EMAIL_NOT_VERIFIED | Email not verified | Returned when the password is correct but the account’s email address has not been verified yet | Complete email verification by clicking the link sent to your inbox, or call /auth/resend-verification, or complete a password reset which also implicitly verifies the email |
{ "statusCode": 403, "error": "ACCOUNT_BANNED", "message": "Your account is banned"}| Error Code | Title | Description | Resolution |
|---|---|---|---|
ACCOUNT_BANNED | Account banned | Your account has been banned and cannot access this resource | Contact support for information about your account status |
Validation Error: the request does not match the operation schema, or its JSON body cannot be parsed. message names each failing check, and data, when present, lists them one entry per failing field.
{ "statusCode": 422, "error": "Validation Error", "message": "Validation failed: /name must NOT have fewer than 3 characters"}POST /api/v1/users/auth/logout
Section titled “POST /api/v1/users/auth/logout”Signs the account out on EVERY device, not only this client. Called with a session JWT, it revokes every access and refresh JWT issued to the account before this moment: other browsers, the CLI, other apps and this client all have to sign in again. Long-lived auth tokens (hdy_…) are not affected; revoke those with DELETE /api/v1/auth/tokens/{id}. Called with an auth token or HTTP Basic credentials, nothing is revoked and only the audit entry is written. A client should also drop its own stored access token, refresh token and credentials, or its automatic re-login signs it straight back in. Creates an audit log entry. Works even for banned users.
This endpoint takes no parameters and no request body.
Response
Section titled “Response”{ "statusCode": 200, "message": "Logout successful"}{ "statusCode": 401, "error": "MISSING_TOKEN", "message": "Authentication token required"}| Error Code | Title | Description | Resolution |
|---|---|---|---|
MISSING_TOKEN | Authentication token missing | No authentication token was provided in the request | Include a valid JWT token in the Authorization header as Bearer <token> |
INVALID_TOKEN | Invalid authentication token | The provided authentication token is malformed or invalid | Obtain a new token by logging in again or using a valid auth token |
TOKEN_EXPIRED | Authentication token expired | The provided authentication token has expired | Obtain a new token by logging in again or refreshing your session |
AUTH_TOKEN_LINEAGE_REVOKED | Parent auth token no longer valid | The auth token was created by another auth token, and that token (or one above it) has been revoked, disabled or has expired, so this one no longer authenticates | Create a new auth token from a valid one, or sign in again |
EMAIL_NOT_VERIFIED | Email not verified | Returned by the login endpoint when the password is correct but the account’s email address has not been verified yet. It is only returned after the password has been checked. The response carries data.email so the client can offer a ‘resend verification email’ action without asking again. | Complete email verification by clicking the link sent to your inbox, or call /auth/resend-verification to receive a new link, or complete a password reset which also implicitly verifies the email. |
INVALID_OTP_CODE | Invalid OTP code | The provided 2FA code is incorrect or has expired | Generate a new code from your authenticator app and try again |
{ "statusCode": 403, "error": "INSUFFICIENT_PERMISSIONS", "message": "Insufficient permissions"}| Error Code | Title | Description | Resolution |
|---|---|---|---|
INSUFFICIENT_PERMISSIONS | Insufficient permissions | You do not have the required permissions to perform this action | Ask the resource owner to grant you access |
ACCOUNT_BANNED | Account banned | Your account has been banned and cannot access this resource | Contact support for information about your account status |
Validation Error: the request does not match the operation schema, or its JSON body cannot be parsed. message names each failing check, and data, when present, lists them one entry per failing field.
{ "statusCode": 422, "error": "Validation Error", "message": "Validation failed: /name must NOT have fewer than 3 characters"}curl -X POST https://api.hoody.com/api/v1/users/auth/logout \ -H 'Authorization: Bearer <jwt_token>'import { HoodyClient } from 'hoody-sdk';
const client = new HoodyClient({ baseURL: 'https://api.hoody.com', token: process.env.HOODY_TOKEN });await client.api.auth.logoutAll();POST /api/v1/users/auth/refresh
Section titled “POST /api/v1/users/auth/refresh”Exchange a valid refresh token for a new access token and new refresh token. Send the refresh token in the Authorization header: Authorization: Bearer {refreshToken}.
Request Body
Section titled “Request Body”| Field | Type | Required | Description |
|---|---|---|---|
refreshToken | string | Yes | Valid refresh token from previous login/refresh. |
curl -X POST https://api.hoody.com/api/v1/users/auth/refresh \ -H 'Content-Type: application/json' \ -d '{ "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." }'import { HoodyClient } from 'hoody-sdk';
const client = new HoodyClient({ baseURL: 'https://api.hoody.com', token: process.env.HOODY_TOKEN });await client.api.auth.refresh({ refreshToken: '<refresh_token>' });Response
Section titled “Response”{ "statusCode": 200, "message": "Token refreshed successfully", "data": { "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", "expires_at": "2025-11-28T20:19:00.000Z", "expires_in": 86400, "refresh_expires_at": "2025-12-04T20:19:00.000Z", "refresh_expires_in": 604800 }}{ "statusCode": 401, "error": "MISSING_TOKEN", "message": "Authentication token required"}| Error Code | Title | Description | Resolution |
|---|---|---|---|
MISSING_TOKEN | Authentication token missing | No authentication token was provided in the request | Include a valid JWT token in the Authorization header as Bearer <token> |
INVALID_TOKEN | Invalid authentication token | The provided authentication token is malformed or invalid | Obtain a new token by logging in again or using a valid auth token |
TOKEN_EXPIRED | Authentication token expired | The provided authentication token has expired | Obtain a new token by logging in again or refreshing your session |
{ "statusCode": 403, "error": "ACCOUNT_BANNED", "message": "Your account is banned"}| Error Code | Title | Description | Resolution |
|---|---|---|---|
ACCOUNT_BANNED | Account banned | Your account has been banned and cannot access this resource | Contact support for information about your account status |
Validation Error: the request does not match the operation schema, or its JSON body cannot be parsed. message names each failing check, and data, when present, lists them one entry per failing field.
{ "statusCode": 422, "error": "Validation Error", "message": "Validation failed: /name must NOT have fewer than 3 characters"}GET /api/v1/users/auth/me
Section titled “GET /api/v1/users/auth/me”Retrieve the profile of the currently authenticated user. Works with JWT, auth token, or Basic authentication. When authenticated with an auth token, response includes data.auth_token introspection details (permissions and realm restrictions). This endpoint works even for banned users (read-only access). Note: when authenticated with an auth token that lacks the resources.read_account permission, the response is reduced to identity fields (id, username, alias, public_key, timestamps); email and other account PII are omitted.
This endpoint takes no parameters.
Response
Section titled “Response”{ "statusCode": 200, "message": "Current user retrieved successfully", "data": { "id": "507f1f77bcf86cd799439011", "username": "john_doe", "alias": "John Doe", "email": "john.doe@example.com", "public_key": "a1b2c3d4e5f6789012345678901234567890abcdefabcdefabcdefabcdef1234", "metadata": { "department": "Engineering" }, "is_banned": false, "email_verified": true, "avatar_url": "https://avatars.githubusercontent.com/u/1234567", "signup_method": "email", "free_tier_unlocked": true, "free_tier_unlocked_at": "2026-06-24T10:00:00.000Z", "free_tier_unlock_source": "invite_code", "onboarding": { "hub_tour_v1": "2026-06-30T10:00:00.000Z" }, "created_at": "2024-12-01T10:00:00.000Z", "updated_at": "2025-11-27T10:30:00.000Z", "pending_pool_invitations": 0 }}{ "statusCode": 401, "error": "MISSING_TOKEN", "message": "Authentication token required"}| Error Code | Title | Description | Resolution |
|---|---|---|---|
MISSING_TOKEN | Authentication token missing | No authentication token was provided in the request | Include a valid JWT token in the Authorization header as Bearer <token> |
INVALID_TOKEN | Invalid authentication token | The provided authentication token is malformed or invalid | Obtain a new token by logging in again or using a valid auth token |
TOKEN_EXPIRED | Authentication token expired | The provided authentication token has expired | Obtain a new token by logging in again or refreshing your session |
AUTH_TOKEN_LINEAGE_REVOKED | Parent auth token no longer valid | The auth token was created by another auth token, and that token (or one above it) has been revoked, disabled or has expired, so this one no longer authenticates | Create a new auth token from a valid one, or sign in again |
EMAIL_NOT_VERIFIED | Email not verified | Returned by the login endpoint when the password is correct but the account’s email address has not been verified yet. It is only returned after the password has been checked. The response carries data.email so the client can offer a ‘resend verification email’ action without asking again. | Complete email verification by clicking the link sent to your inbox, or call /auth/resend-verification to receive a new link, or complete a password reset which also implicitly verifies the email. |
INVALID_OTP_CODE | Invalid OTP code | The provided 2FA code is incorrect or has expired | Generate a new code from your authenticator app and try again |
{ "statusCode": 403, "error": "INSUFFICIENT_PERMISSIONS", "message": "Insufficient permissions"}| Error Code | Title | Description | Resolution |
|---|---|---|---|
INSUFFICIENT_PERMISSIONS | Insufficient permissions | You do not have the required permissions to perform this action | Ask the resource owner to grant you access |
ACCOUNT_BANNED | Account banned | Your account has been banned and cannot access this resource | Contact support for information about your account status |
curl https://api.hoody.com/api/v1/users/auth/me \ -H 'Authorization: Bearer <jwt_token>'import { HoodyClient } from 'hoody-sdk';
const client = new HoodyClient({ baseURL: 'https://api.hoody.com', token: process.env.HOODY_TOKEN });await client.api.auth.whoami();GET /api/v1/users/me
Section titled “GET /api/v1/users/me”Retrieve the profile of the currently authenticated user. Alias of GET /api/v1/users/auth/me returning the same shape and behaviour. Works with JWT, auth token, or Basic authentication. When authenticated with an auth token, response includes data.auth_token introspection details (permissions and realm restrictions). This endpoint works even for banned users (read-only access). Note: when authenticated with an auth token that lacks the resources.read_account permission, the response is reduced to identity fields (id, username, alias, public_key, timestamps); email and other account PII are omitted.
This endpoint takes no parameters.
Response
Section titled “Response”{ "statusCode": 200, "message": "Current user retrieved successfully", "data": { "id": "507f1f77bcf86cd799439011", "username": "john_doe", "alias": "John Doe", "email": "john.doe@example.com", "public_key": "a1b2c3d4e5f6789012345678901234567890abcdefabcdefabcdefabcdef1234", "metadata": {}, "is_banned": false, "email_verified": true, "avatar_url": null, "signup_method": "email", "free_tier_unlocked": true, "free_tier_unlocked_at": "2026-06-24T10:00:00.000Z", "free_tier_unlock_source": "invite_code", "onboarding": { "hub_tour_v1": "2026-06-30T10:00:00.000Z" }, "created_at": "2024-12-01T10:00:00.000Z", "updated_at": "2025-11-27T10:30:00.000Z", "pending_pool_invitations": 0 }}{ "statusCode": 401, "error": "MISSING_TOKEN", "message": "Authentication token required"}| Error Code | Title | Description | Resolution |
|---|---|---|---|
MISSING_TOKEN | Authentication token missing | No authentication token was provided in the request | Include a valid JWT token in the Authorization header as Bearer <token> |
INVALID_TOKEN | Invalid authentication token | The provided authentication token is malformed or invalid | Obtain a new token by logging in again or using a valid auth token |
TOKEN_EXPIRED | Authentication token expired | The provided authentication token has expired | Obtain a new token by logging in again or refreshing your session |
AUTH_TOKEN_LINEAGE_REVOKED | Parent auth token no longer valid | The auth token was created by another auth token, and that token (or one above it) has been revoked, disabled or has expired, so this one no longer authenticates | Create a new auth token from a valid one, or sign in again |
EMAIL_NOT_VERIFIED | Email not verified | Returned by the login endpoint when the password is correct but the account’s email address has not been verified yet. It is only returned after the password has been checked. The response carries data.email so the client can offer a ‘resend verification email’ action without asking again. | Complete email verification by clicking the link sent to your inbox, or call /auth/resend-verification to receive a new link, or complete a password reset which also implicitly verifies the email. |
INVALID_OTP_CODE | Invalid OTP code | The provided 2FA code is incorrect or has expired | Generate a new code from your authenticator app and try again |
{ "statusCode": 403, "error": "INSUFFICIENT_PERMISSIONS", "message": "Insufficient permissions"}| Error Code | Title | Description | Resolution |
|---|---|---|---|
INSUFFICIENT_PERMISSIONS | Insufficient permissions | You do not have the required permissions to perform this action | Ask the resource owner to grant you access |
ACCOUNT_BANNED | Account banned | Your account has been banned and cannot access this resource | Contact support for information about your account status |
curl https://api.hoody.com/api/v1/users/me \ -H 'Authorization: Bearer <jwt_token>'API signing public key
Section titled “API signing public key”GET /api/v1/meta/public-key
Section titled “GET /api/v1/meta/public-key”Returns the ED25519 public key(s) used by Hoody to sign all API responses (X-Hoody-Signature header), identity claims issued at login, and container authorization claims. No authentication required; this endpoint is intentionally public.
Third-party verification flow:
- Fetch this endpoint once (cache for 24 hours or more).
- Locate the key by
kidfrom thekeys[]array. Thekeys[]array is a rotation window: a next key may be pre-published before it is used, and the previous key is retained until all in-flight claims have expired. Always look up bykid, not by array index. - For response signatures: parse
X-Hoody-Signature: t=<unix_ts>,kid=<keyId>,m=<method>,s=<status>,path=<request_url>,sig=<128-hex>and verifysigagainst the newline-joined tuple${t}\n${method}\n${status}\n${path}\n${responseBodyUtf8String}. - For identity and container claims: verify
claim.signature_hexagainst the UTF-8 bytes ofclaim.payload_b64. - If
kidin a signature/claim does not match any cached key, re-fetch this endpoint.
This endpoint takes no parameters.
Response
Section titled “Response”{ "statusCode": 200, "message": "Hoody API signing public key", "data": { "keys": [ { "kid": "v1", "algorithm": "ed25519", "public_key_hex": "8c8d683c125761bd9157e3a6f98c30d81cd7f2be4d16062a8342d1fcd2ca474a", "public_key_b64": "jI1oPBJXYb2RV+Om+YwwwlzX8r5NFgYqg0LRzSykd0o=", "public_key_b64url": "jI1oPBJXYb2RV-Om-YwwwlzX8r5NFgYqg0LRzSykd0o" } ], "active_kid": "v1", "usage": ["response-signing", "identity-claims", "container-claims"], "signing_format": { "response_header": "X-Hoody-Signature: t=<unix_ts>,kid=<key_id>,m=<method>,s=<status>,path=<request_url>,sig=<hex>", "response_signed_data": "<t>\n<method>\n<status>\n<path>\n<response_body_utf8_string>", "identity_claim_signed_data": "base64url(JSON.stringify(claim_payload)) — the b64url string itself (UTF-8 bytes)", "container_claim_signed_data": "base64url(JSON.stringify(container_claim_payload)) — the b64url string itself (UTF-8 bytes)", "replay_tolerance_seconds": 300 } }}The response is itself signed with X-Hoody-Signature; the same algorithm applies (see Third-party verification flow above).
{ "statusCode": 503, "error": "SIGNING_NOT_CONFIGURED", "message": "Response signing is not configured on this API instance"}curl https://api.hoody.com/api/v1/meta/public-keyimport { HoodyClient } from 'hoody-sdk';
const client = new HoodyClient({ baseURL: 'https://api.hoody.com', token: process.env.HOODY_TOKEN });await client.api.meta.getPublicKey();Identity claims
Section titled “Identity claims”An identity claim is an ED25519-signed credential that proves “Hoody authenticated this user” to systems outside Hoody: your own apps, partner services, anything that can fetch a public key. Third parties verify a claim offline against the ED25519 public key returned by GET /api/v1/meta/public-key; no network call back to Hoody is required.
Identity claims are returned by the following flows:
POST /api/v1/users/auth/login(login, whenresponse_mode=tokensor unset and signing is configured)POST /api/v1/users/auth/2fa/verify(2FA verify, same conditions)POST /api/v1/auth/verify-email(verify-email,tokensmode)POST /api/v1/auth/device/token(device-flow terminal, on approval)POST /api/v1/users/auth/identity-claim(audience-bound re-issue)
They are omitted when response_mode=intent is selected and when identity signing is unavailable. Container claims use the same wire format with claim_type set to "container". See the containers reference for how to verify a payload.aud against a specific {projectId}-{containerId}-{service}-{instance}.{server}.containers.hoody.com hostname.
Bundle format
Section titled “Bundle format”| Field | Type | Description |
|---|---|---|
kid | string | Key identifier. Look up the matching public_key_b64url in GET /api/v1/meta/public-key keys[]. |
payload_b64 | string | base64url-encoded JSON payload. The signature is computed over the UTF-8 bytes of this exact string. |
signature_hex | string | ED25519 detached signature (128 hex characters = 64 bytes). |
Payload format
Section titled “Payload format”| Field | Type | Description |
|---|---|---|
claim_type | string | Always "identity" for identity claims (vs "container" for container claims). |
iss | string | Always "hoody-api". |
sub | string | User ID (24 hex characters). |
username | string | Username of the authenticated user. |
type | string | Account type: "user" or "admin". Informational only. |
iat | number | Issued-at (Unix seconds). |
exp | number | Expiry (Unix seconds). Default ~30 days for login-derived claims; shorter for re-issued audience-bound claims. |
kid | string | Key identifier, must match bundle.kid. |
aud | string | (Audience-bound re-issued claims only) Consumer identifier the claim is bound to. |
Seven MANDATORY verification checks
Section titled “Seven MANDATORY verification checks”When verifying a claim, run every check below. A failure on any one is a rejection.
- Locate the key: look up
bundle.kidin the cachedkeys[]fromGET /api/v1/meta/public-keyAND verifypayload.kid === bundle.kid. Re-fetch the endpoint if thekidis unknown. - Verify the ED25519 detached signature over the UTF-8 bytes of
bundle.payload_b64. payload.claim_type === "identity".payload.iss === "hoody-api".payload.exp > nowandpayload.exp > payload.iat.payload.iat <= now + 300(5 minute clock-skew tolerance).- Audience binding (strict two-way): if your verifier expects an audience,
payload.audMUST equal exactly the verifier’s identifier. A claim with noaudfield is rejected by any audience-aware verifier; a claim with anaudthat does not match is rejected.
Working verifier examples
Section titled “Working verifier examples”import * as crypto from 'crypto';
// ED25519 SPKI prefix (ASN.1 DER header for the algorithm OID + the raw 32-byte key).// Required by Node's crypto.verify when the key is supplied as DER/SPKI.const ED25519_SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex');
interface PublicKeySet { keys: Array<{ kid: string; public_key_b64url: string }>;}
interface IdentityClaim { kid: string; payload_b64: string; signature_hex: string;}
async function loadKeys(): Promise<PublicKeySet> { const res = await fetch('https://api.hoody.com/api/v1/meta/public-key'); const json = await res.json() as { data: PublicKeySet }; return json.data;}
export function verifyIdentityClaim( claim: IdentityClaim, keys: PublicKeySet, expectedAudience?: string): { ok: true; payload: any } | { ok: false; reason: string } { // (1) Locate the key by kid. const entry = keys.keys.find((k) => k.kid === claim.kid); if (!entry) return { ok: false, reason: 'unknown kid' };
// Decode the raw 32-byte public key, then prefix with the ED25519 SPKI OID. const rawPubKey = Buffer.from(entry.public_key_b64url, 'base64url'); const derPubKey = Buffer.concat([ED25519_SPKI_PREFIX, rawPubKey]);
// Verify the detached signature over the UTF-8 bytes of payload_b64. const signatureOk = crypto.verify( null, Buffer.from(claim.payload_b64, 'utf8'), { key: derPubKey, format: 'der', type: 'spki' }, Buffer.from(claim.signature_hex, 'hex') ); if (!signatureOk) return { ok: false, reason: 'signature mismatch' };
// Decode the payload for the structural checks. const payload = JSON.parse(Buffer.from(claim.payload_b64, 'base64url').toString('utf8'));
const now = Math.floor(Date.now() / 1000); const checks: Array<[string, boolean]> = [ ['claim_type === "identity"', payload.claim_type === 'identity'], ['iss === "hoody-api"', payload.iss === 'hoody-api'], ['exp > now', payload.exp > now], ['exp > iat', payload.exp > payload.iat], ['iat <= now + 300', payload.iat <= now + 300], ['payload.kid === bundle.kid', payload.kid === claim.kid], [ 'audience binding', expectedAudience === undefined ? true : typeof payload.aud === 'string' && payload.aud === expectedAudience ] ];
for (const [label, ok] of checks) { if (!ok) return { ok: false, reason: `check failed: ${label}` }; }
return { ok: true, payload };}import nacl from 'tweetnacl';import { decode as base64urlDecode } from 'base64url';
interface IdentityClaim { kid: string; payload_b64: string; signature_hex: string;}
function hexToBytes(hex: string): Uint8Array { return Uint8Array.from(hex.match(/.{1,2}/g)!.map((b) => parseInt(b, 16)));}
export function verifyIdentityClaim( claim: IdentityClaim, keys: { keys: Array<{ kid: string; public_key_b64url: string }> }, expectedAudience?: string): { ok: true; payload: any } | { ok: false; reason: string } { const entry = keys.keys.find((k) => k.kid === claim.kid); if (!entry) return { ok: false, reason: 'unknown kid' };
const publicKey = base64urlDecode(entry.public_key_b64url); const signature = hexToBytes(claim.signature_hex); const message = new TextEncoder().encode(claim.payload_b64);
// (1) Detached ED25519 verification over the UTF-8 bytes of payload_b64. if (!nacl.sign.detached.verify(message, signature, publicKey)) { return { ok: false, reason: 'signature mismatch' }; }
const payload = JSON.parse(new TextDecoder().decode(base64urlDecode(claim.payload_b64)));
const now = Math.floor(Date.now() / 1000); const checks: Array<[string, boolean]> = [ ['claim_type === "identity"', payload.claim_type === 'identity'], ['iss === "hoody-api"', payload.iss === 'hoody-api'], ['exp > now', payload.exp > now], ['exp > iat', payload.exp > payload.iat], ['iat <= now + 300', payload.iat <= now + 300], ['payload.kid === bundle.kid', payload.kid === claim.kid], [ 'audience binding', expectedAudience === undefined ? true : typeof payload.aud === 'string' && payload.aud === expectedAudience ] ];
for (const [label, ok] of checks) { if (!ok) return { ok: false, reason: `check failed: ${label}` }; }
return { ok: true, payload };}Decodable example claim
Section titled “Decodable example claim”kid: v1payload_b64: eyJjbGFpbV90eXBlIjoiaWRlbnRpdHkiLCJpc3MiOiJob29keS1hcGkiLCJzdWIiOiI1MDdmMWY3N2JjZjg2Y2Q3OTk0MzkwMTEiLCJ1c2VybmFtZSI6ImpvaG5fZG9lIiwidHlwZSI6InVzZXIiLCJpYXQiOjE3NDEyOTAwMDAsImV4cCI6MTc0Mzg4MjAwMCwia2lkIjoidjEifQpayload (decoded): { "claim_type": "identity", "iss": "hoody-api", "sub": "507f1f77bcf86cd799439011", "username": "john_doe", "type": "user", "iat": 1741290000, "exp": 1743882000, "kid": "v1" }signature_hex: abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234 abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234This example uses aud-less payload semantics (login-derived claim). See Security below for why re-issued claims with aud are preferred.
Security
Section titled “Security”Gating proxied apps with an identity claim
Section titled “Gating proxied apps with an identity claim”When a Hoody container fronts another application (via the proxy), the application can require callers to present a valid identity claim. Three patterns are supported, in order of preference:
1. Native hoody-identity permission group
Section titled “1. Native hoody-identity permission group”The cleanest path: grant a proxy permission with the built-in hoody-identity group. The edge validates the X-Hoody-Identity-Claim (and related) headers before forwarding the request, no app-side crypto required. See the proxy permissions reference for the full grant syntax.
2. App-level verification via a non-reserved header
Section titled “2. App-level verification via a non-reserved header”If the app prefers to do its own verification, carry the claim in a header outside the reserved X-Hoody-Identity-* namespace. The recommended convention is:
X-Hoody-Claim: <payload_b64>.<signature_hex>The app pins a known kid (resolved via GET /api/v1/meta/public-key), then runs the seven verification checks above against the expected audience (typically the app’s own hostname or service identifier).
3. Proxy hook
Section titled “3. Proxy hook”For full custom logic (audience mapping, replay protection, per-route policies), write a proxy hook. The reference recipe, identity-claim-auth-gate, likewise uses the non-reserved x-hoody-claim header to receive the bundle. See the proxy hooks reference.
POST /api/v1/users/auth/identity-claim
Section titled “POST /api/v1/users/auth/identity-claim”Mints a fresh, audience-bound identity claim for the authenticated caller without requiring a re-login. First-party JWT sessions only: auth tokens, HTTP Basic, and impersonated sessions are rejected (403). The claim proves “Hoody authenticated this user” to the audience named in the request; third parties verify it offline against GET /api/v1/meta/public-key. Claim lifetime is clamped to [60s, min(server ceiling — default 24h, remaining JWT lifetime)], with a default of 1 hour. When fewer than 60 seconds remain on the caller’s JWT, this endpoint returns 400 REFRESH_REQUIRED rather than minting a claim that would expire before it can be used. Two rate limits apply: the standard per-IP flood guard and a tighter per-session cap.
Request Body
Section titled “Request Body”| Field | Type | Required | Description |
|---|---|---|---|
audience | string | Yes | Consumer identifier this claim is bound to (e.g. your app hostname). Verifiers reject the claim unless they expect exactly this audience. Printable ASCII, no whitespace or double quotes. |
expires_in | integer | No | Requested claim lifetime in seconds. Clamped to [60, min(server ceiling, remaining JWT lifetime)]. Default: server-configured (1 hour). |
curl -X POST https://api.hoody.com/api/v1/users/auth/identity-claim \ -H 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...' \ -H 'Content-Type: application/json' \ -d '{ "audience": "myapp.example.com", "expires_in": 3600 }'import { HoodyClient } from 'hoody-sdk';
const client = new HoodyClient({ baseURL: 'https://api.hoody.com', token: process.env.HOODY_TOKEN });await client.api.auth.createIdentityClaim({ audience: 'myapp.example.com', expires_in: 3600});Response
Section titled “Response”{ "statusCode": 200, "message": "Identity claim issued", "data": { "identity_claim": { "kid": "v1", "payload_b64": "eyJjbGFpbV90eXBlIjoiaWRlbnRpdHkiLCJpc3MiOiJob29keS1hcGkiLCJzdWIiOiI1MDdmMWY3N2JjZjg2Y2Q3OTk0MzkwMTEiLCJ1c2VybmFtZSI6ImpvaG5fZG9lIiwidHlwZSI6InVzZXIiLCJpYXQiOjE3NDEyOTAwMDAsImV4cCI6MTc0Mzg4MjAwMCwia2lkIjoidjEiLCJhdWQiOiJteWFwcC5leGFtcGxlLmNvbSJ9", "signature_hex": "abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef12" }, "expires_in": 3600, "expires_at": "2026-01-15T11:30:00.000Z", "audience": "myapp.example.com" }}{ "statusCode": 400, "error": "REFRESH_REQUIRED", "message": "JWT lifetime is too short to mint a useful claim; refresh the session and retry."}The error field is VALIDATION_ERROR for malformed bodies and REFRESH_REQUIRED when fewer than 60 seconds remain on the JWT.
{ "statusCode": 401, "error": "UNAUTHORIZED", "message": "JWT is invalid or expired."}The error field is UNAUTHORIZED for invalid tokens and SESSION_REVOKED when the session was ended (logout-all, ban, or credential change).
| Error Code | Title | Description | Resolution |
|---|---|---|---|
AUTH_TOKEN_LINEAGE_REVOKED | Parent auth token no longer valid | The auth token was created by another auth token, and that token (or one above it) has been revoked, disabled or has expired, so this one no longer authenticates | Create a new auth token from a valid one, or sign in again |
EMAIL_NOT_VERIFIED | Email not verified | Returned by the login endpoint when the password is correct but the account’s email address has not been verified yet. It is only returned after the password has been checked. The response carries data.email so the client can offer a ‘resend verification email’ action without asking again. | Complete email verification by clicking the link sent to your inbox, or call /auth/resend-verification to receive a new link, or complete a password reset which also implicitly verifies the email. |
INVALID_OTP_CODE | Invalid OTP code | The provided 2FA code is incorrect or has expired | Generate a new code from your authenticator app and try again |
{ "statusCode": 403, "error": "FORBIDDEN", "message": "Identity claims can only be minted by first-party JWT sessions."}The error field is FORBIDDEN for auth-token / Basic / impersonation callers and ACCOUNT_UNAVAILABLE for banned accounts.
| Error Code | Title | Description | Resolution |
|---|---|---|---|
INSUFFICIENT_PERMISSIONS | Insufficient permissions | You do not have the required permissions to perform this action | Ask the resource owner to grant you access |
ACCOUNT_BANNED | Account banned | Your account has been banned and cannot access this resource | Contact support for information about your account status |
Validation Error: the request does not match the operation schema, or its JSON body cannot be parsed. message names each failing check, and data, when present, lists them one entry per failing field.
{ "statusCode": 422, "error": "Validation Error", "message": "Validation failed: /name must NOT have fewer than 3 characters"}{ "statusCode": 503, "error": "SIGNING_NOT_CONFIGURED", "message": "Response signing is not configured on this API instance."}| Error Code | Title | Description | Resolution |
|---|---|---|---|
SIGNING_NOT_CONFIGURED | Signing not configured | — | — |
Two-factor authentication
Section titled “Two-factor authentication”GET /api/v1/users/auth/2fa/status
Section titled “GET /api/v1/users/auth/2fa/status”Check the current 2FA status for the authenticated user, including whether it is enabled and how many backup codes remain.
This endpoint takes no parameters.
Response
Section titled “Response”{ "statusCode": 200, "message": "2FA status retrieved", "data": { "enabled": true, "verified": true, "enabled_at": "2025-01-14T21:00:00.000Z", "backup_codes_remaining": 8, "require_for_tokens": true }}{ "statusCode": 401, "error": "MISSING_TOKEN", "message": "Authentication token required"}| Error Code | Title | Description | Resolution |
|---|---|---|---|
MISSING_TOKEN | Authentication token missing | No authentication token was provided in the request | Include a valid JWT token in the Authorization header as Bearer <token> |
INVALID_TOKEN | Invalid authentication token | The provided authentication token is malformed or invalid | Obtain a new token by logging in again or using a valid auth token |
TOKEN_EXPIRED | Authentication token expired | The provided authentication token has expired | Obtain a new token by logging in again or refreshing your session |
INVALID_OTP_CODE | Invalid OTP code | The provided 2FA code is incorrect or has expired | Generate a new code from your authenticator app and try again |
AUTH_TOKEN_LINEAGE_REVOKED | Parent auth token no longer valid | The auth token was created by another auth token, and that token (or one above it) has been revoked, disabled or has expired, so this one no longer authenticates | Create a new auth token from a valid one, or sign in again |
EMAIL_NOT_VERIFIED | Email not verified | Returned by the login endpoint when the password is correct but the account’s email address has not been verified yet. It is only returned after the password has been checked. The response carries data.email so the client can offer a ‘resend verification email’ action without asking again. | Complete email verification by clicking the link sent to your inbox, or call /auth/resend-verification to receive a new link, or complete a password reset which also implicitly verifies the email. |
{ "statusCode": 403, "error": "INSUFFICIENT_PERMISSIONS", "message": "Insufficient permissions"}| Error Code | Title | Description | Resolution |
|---|---|---|---|
INSUFFICIENT_PERMISSIONS | Insufficient permissions | You do not have the required permissions to perform this action | Ask the resource owner to grant you access |
ACCOUNT_BANNED | Account banned | Your account has been banned and cannot access this resource | Contact support for information about your account status |
{ "statusCode": 429, "error": "TWOFACTOR_RATE_LIMIT", "message": "Too many failed attempts. Account locked for 15 minutes.", "data": { "lockout_seconds": 900 }}| Error Code | Title | Description | Resolution |
|---|---|---|---|
TWOFACTOR_RATE_LIMIT | 2FA verification locked | Too many failed 2FA verification attempts. Account is temporarily locked | Wait for the lockout period to expire (15 minutes) before trying again |
curl https://api.hoody.com/api/v1/users/auth/2fa/status \ -H 'Authorization: Bearer <jwt_token>'import { HoodyClient } from 'hoody-sdk';
const client = new HoodyClient({ baseURL: 'https://api.hoody.com', token: process.env.HOODY_TOKEN });await client.api.auth.twoFactor.getStatus();POST /api/v1/users/auth/2fa/setup
Section titled “POST /api/v1/users/auth/2fa/setup”Begin 2FA setup. Requires the current account password for verification. Returns the QR code for the authenticator app and backup codes. Important: save backup codes securely, they are shown only once.
Request Body
Section titled “Request Body”| Field | Type | Required | Description |
|---|---|---|---|
password | string | Yes | Current account password for verification. |
curl -X POST https://api.hoody.com/api/v1/users/auth/2fa/setup \ -H 'Authorization: Bearer <jwt_token>' \ -H 'Content-Type: application/json' \ -d '{ "password": "SecurePassword123!" }'import { HoodyClient } from 'hoody-sdk';
const client = new HoodyClient({ baseURL: 'https://api.hoody.com', token: process.env.HOODY_TOKEN });await client.api.auth.twoFactor.startSetup({ password: 'SecurePassword123!' });Response
Section titled “Response”{ "statusCode": 200, "message": "2FA setup initiated", "data": { "qr_code": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA...", "manual_entry_key": "JBSWY3DPEHPK3PXP", "backup_codes": [ "a1b2c3d4e5", "f6g7h8i9j0", "k1l2m3n4o5", "p6q7r8s9t0", "u1v2w3x4y5", "z6a7b8c9d0", "e1f2g3h4i5", "j6k7l8m9n0", "o1p2q3r4s5", "t6u7v8w9x0" ] }}{ "statusCode": 400, "error": "INCORRECT_PASSWORD", "message": "Password is incorrect"}| Error Code | Title | Description | Resolution |
|---|---|---|---|
VALIDATION_ERROR | Invalid input parameters | One or more request parameters failed validation | Check the error message for specific field requirements and correct your input |
MISSING_REQUIRED_FIELD | Required field missing | One or more required fields are missing from the request | Include all required fields as specified in the API documentation |
INCORRECT_PASSWORD | Incorrect password | The provided password does not match the account password | Verify your password and try again |
TWOFACTOR_ALREADY_ENABLED | 2FA already enabled | Two-factor authentication is already enabled for this account | Disable 2FA first if you want to set it up again |
{ "statusCode": 401, "error": "MISSING_TOKEN", "message": "Authentication token required"}| Error Code | Title | Description | Resolution |
|---|---|---|---|
MISSING_TOKEN | Authentication token missing | No authentication token was provided in the request | Include a valid JWT token in the Authorization header as Bearer <token> |
INVALID_TOKEN | Invalid authentication token | The provided authentication token is malformed or invalid | Obtain a new token by logging in again or using a valid auth token |
TOKEN_EXPIRED | Authentication token expired | The provided authentication token has expired | Obtain a new token by logging in again or refreshing your session |
AUTH_TOKEN_LINEAGE_REVOKED | Parent auth token no longer valid | The auth token was created by another auth token, and that token (or one above it) has been revoked, disabled or has expired, so this one no longer authenticates | Create a new auth token from a valid one, or sign in again |
EMAIL_NOT_VERIFIED | Email not verified | Returned by the login endpoint when the password is correct but the account’s email address has not been verified yet. It is only returned after the password has been checked. The response carries data.email so the client can offer a ‘resend verification email’ action without asking again. | Complete email verification by clicking the link sent to your inbox, or call /auth/resend-verification to receive a new link, or complete a password reset which also implicitly verifies the email. |
INVALID_OTP_CODE | Invalid OTP code | The provided 2FA code is incorrect or has expired | Generate a new code from your authenticator app and try again |
{ "statusCode": 403, "error": "INSUFFICIENT_PERMISSIONS", "message": "Insufficient permissions"}| Error Code | Title | Description | Resolution |
|---|---|---|---|
INSUFFICIENT_PERMISSIONS | Insufficient permissions | You do not have the required permissions to perform this action | Ask the resource owner to grant you access |
ACCOUNT_BANNED | Account banned | Your account has been banned and cannot access this resource | Contact support for information about your account status |
Validation Error: the request does not match the operation schema, or its JSON body cannot be parsed. message names each failing check, and data, when present, lists them one entry per failing field.
{ "statusCode": 422, "error": "Validation Error", "message": "Validation failed: /name must NOT have fewer than 3 characters"}POST /api/v1/users/auth/2fa/verify-setup
Section titled “POST /api/v1/users/auth/2fa/verify-setup”Verify and complete 2FA setup by providing the first code from your authenticator app. This confirms the setup is working correctly. On success, all sessions are revoked and a fresh token / refreshToken pair is returned (the response carries data.token, data.refreshToken, and data.sessions_revoked: true).
Request Body
Section titled “Request Body”| Field | Type | Required | Description |
|---|---|---|---|
code | string | Yes | 6-digit code from the authenticator app. |
curl -X POST https://api.hoody.com/api/v1/users/auth/2fa/verify-setup \ -H 'Authorization: Bearer <jwt_token>' \ -H 'Content-Type: application/json' \ -d '{ "code": "123456" }'import { HoodyClient } from 'hoody-sdk';
const client = new HoodyClient({ baseURL: 'https://api.hoody.com', token: process.env.HOODY_TOKEN });await client.api.auth.twoFactor.confirmSetup({ code: '123456' });Response
Section titled “Response”{ "statusCode": 200, "message": "2FA successfully enabled. All other sessions have been signed out.", "data": { "enabled": true, "enabled_at": "2025-01-14T21:00:00.000Z", "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", "sessions_revoked": true }}{ "statusCode": 400, "error": "INVALID_OTP_CODE", "message": "Invalid or expired 2FA code"}| Error Code | Title | Description | Resolution |
|---|---|---|---|
VALIDATION_ERROR | Invalid input parameters | One or more request parameters failed validation | Check the error message for specific field requirements and correct your input |
MISSING_REQUIRED_FIELD | Required field missing | One or more required fields are missing from the request | Include all required fields as specified in the API documentation |
INVALID_OTP_CODE | Invalid OTP code | The provided 2FA code is incorrect or has expired | Generate a new code from your authenticator app and try again |
TWOFACTOR_NOT_VERIFIED | 2FA setup not verified | 2FA setup was initiated but not yet verified | Complete the setup by verifying your first code |
{ "statusCode": 401, "error": "MISSING_TOKEN", "message": "Authentication token required"}| Error Code | Title | Description | Resolution |
|---|---|---|---|
MISSING_TOKEN | Authentication token missing | No authentication token was provided in the request | Include a valid JWT token in the Authorization header as Bearer <token> |
INVALID_TOKEN | Invalid authentication token | The provided authentication token is malformed or invalid | Obtain a new token by logging in again or using a valid auth token |
TOKEN_EXPIRED | Authentication token expired | The provided authentication token has expired | Obtain a new token by logging in again or refreshing your session |
INVALID_OTP_CODE | Invalid OTP code | The provided 2FA code is incorrect or has expired | Generate a new code from your authenticator app and try again |
AUTH_TOKEN_LINEAGE_REVOKED | Parent auth token no longer valid | The auth token was created by another auth token, and that token (or one above it) has been revoked, disabled or has expired, so this one no longer authenticates | Create a new auth token from a valid one, or sign in again |
EMAIL_NOT_VERIFIED | Email not verified | Returned by the login endpoint when the password is correct but the account’s email address has not been verified yet. It is only returned after the password has been checked. The response carries data.email so the client can offer a ‘resend verification email’ action without asking again. | Complete email verification by clicking the link sent to your inbox, or call /auth/resend-verification to receive a new link, or complete a password reset which also implicitly verifies the email. |
{ "statusCode": 403, "error": "INSUFFICIENT_PERMISSIONS", "message": "Insufficient permissions"}| Error Code | Title | Description | Resolution |
|---|---|---|---|
INSUFFICIENT_PERMISSIONS | Insufficient permissions | You do not have the required permissions to perform this action | Ask the resource owner to grant you access |
ACCOUNT_BANNED | Account banned | Your account has been banned and cannot access this resource | Contact support for information about your account status |
Validation Error: the request does not match the operation schema, or its JSON body cannot be parsed. message names each failing check, and data, when present, lists them one entry per failing field.
{ "statusCode": 422, "error": "Validation Error", "message": "Validation failed: /name must NOT have fewer than 3 characters"}{ "statusCode": 429, "error": "TWOFACTOR_RATE_LIMIT", "message": "Too many failed attempts. Account locked for 15 minutes.", "data": { "lockout_seconds": 900 }}| Error Code | Title | Description | Resolution |
|---|---|---|---|
TWOFACTOR_RATE_LIMIT | 2FA verification locked | Too many failed 2FA verification attempts. Account is temporarily locked | Wait for the lockout period to expire (15 minutes) before trying again |
POST /api/v1/users/auth/2fa/verify
Section titled “POST /api/v1/users/auth/2fa/verify”Complete login by verifying a 2FA code. Use the temp_token from the login response and provide either a 6-digit OTP code or a backup code. On success, the response includes an identity claim under data.identity_claim proving “Hoody authenticated this user”.
Request Body
Section titled “Request Body”| Field | Type | Required | Description |
|---|---|---|---|
temp_token | string | No | Temporary token from login response (valid for 5 minutes). Alternatively pass it as Authorization: Bearer header. |
code | string | Yes | 6-digit OTP code from authenticator app OR 10-character backup code. |
response_mode | string | No | Response shape. tokens (default) returns access/refresh tokens. intent returns an opaque auth_intent_token for PKCE exchange. |
client | string | No | Optional client-declared source channel for analytics. |
curl -X POST https://api.hoody.com/api/v1/users/auth/2fa/verify \ -H 'Authorization: Bearer <temp_token>' \ -H 'Content-Type: application/json' \ -d '{ "code": "123456" }'import { HoodyClient } from 'hoody-sdk';
const client = new HoodyClient({ baseURL: 'https://api.hoody.com', token: process.env.HOODY_TOKEN });await client.api.auth.twoFactor.verify({ code: '123456' });Response
Section titled “Response”{ "statusCode": 200, "message": "Authentication successful", "data": { "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", "identity_claim": { "kid": "v1", "payload_b64": "eyJjbGFpbV90eXBlIjoiaWRlbnRpdHkiLCJpc3MiOiJob29keS1hcGkiLCJzdWIiOiI1MDdmMWY3N2JjZjg2Y2Q3OTk0MzkwMTEiLCJ1c2VybmFtZSI6ImpvaG5fZG9lIiwidHlwZSI6InVzZXIiLCJpYXQiOjE3NDEyOTAwMDAsImV4cCI6MTc0Mzg4MjAwMCwia2lkIjoidjEifQ", "signature_hex": "abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef12" }, "user": { "id": "507f1f77bcf86cd799439011", "alias": "John Doe" } }}{ "statusCode": 400, "error": "INVALID_OTP_CODE", "message": "Invalid or expired 2FA code"}| Error Code | Title | Description | Resolution |
|---|---|---|---|
VALIDATION_ERROR | Invalid input parameters | One or more request parameters failed validation | Check the error message for specific field requirements and correct your input |
MISSING_REQUIRED_FIELD | Required field missing | One or more required fields are missing from the request | Include all required fields as specified in the API documentation |
INVALID_OTP_CODE | Invalid OTP code | The provided 2FA code is incorrect or has expired | Generate a new code from your authenticator app and try again |
INVALID_BACKUP_CODE | Invalid backup code | The provided backup code is incorrect or has already been used | Verify the backup code is correct and has not been used previously |
INVALID_TEMP_TOKEN | Invalid temporary token | The temporary token from login has expired or is invalid | Log in again to get a new temporary token |
DEVICE_BINDING_GONE | Device authorization no longer pending | The device-flow partial token references an authorization attempt that expired, was denied, was superseded, or was already completed | Return to the device verification page and re-enter the code, or restart the login from the terminal |
{ "statusCode": 401, "error": "INVALID_OTP_CODE", "message": "Invalid or expired 2FA code"}| Error Code | Title | Description | Resolution |
|---|---|---|---|
INVALID_OTP_CODE | Invalid OTP code | The provided 2FA code is incorrect or has expired | Generate a new code from your authenticator app and try again |
INVALID_BACKUP_CODE | Invalid backup code | The provided backup code is incorrect or has already been used | Verify the backup code is correct and has not been used previously |
{ "statusCode": 403, "error": "ACCOUNT_BANNED", "message": "Your account is banned"}| Error Code | Title | Description | Resolution |
|---|---|---|---|
ACCOUNT_BANNED | Account banned | Your account has been banned and cannot access this resource | Contact support for information about your account status |
Validation Error: the request does not match the operation schema, or its JSON body cannot be parsed. message names each failing check, and data, when present, lists them one entry per failing field.
{ "statusCode": 422, "error": "Validation Error", "message": "Validation failed: /name must NOT have fewer than 3 characters"}{ "statusCode": 429, "error": "TWOFACTOR_RATE_LIMIT", "message": "Too many failed attempts. Account locked for 15 minutes.", "data": { "lockout_seconds": 900 }}| Error Code | Title | Description | Resolution |
|---|---|---|---|
TWOFACTOR_RATE_LIMIT | 2FA verification locked | Too many failed 2FA verification attempts. Account is temporarily locked | Wait for the lockout period to expire (15 minutes) before trying again |
POST /api/v1/users/auth/2fa/backup-codes/regenerate
Section titled “POST /api/v1/users/auth/2fa/backup-codes/regenerate”Generate new backup codes (invalidates all existing ones). Requires the password and current OTP code for security. Important: save the new codes securely!
Request Body
Section titled “Request Body”| Field | Type | Required | Description |
|---|---|---|---|
password | string | Yes | Current account password. |
code | string | Yes | 6-digit OTP code from authenticator app. |
curl -X POST https://api.hoody.com/api/v1/users/auth/2fa/backup-codes/regenerate \ -H 'Authorization: Bearer <jwt_token>' \ -H 'Content-Type: application/json' \ -d '{ "password": "SecurePassword123!", "code": "123456" }'import { HoodyClient } from 'hoody-sdk';
const client = new HoodyClient({ baseURL: 'https://api.hoody.com', token: process.env.HOODY_TOKEN });await client.api.auth.twoFactor.rotateBackupCodes({ password: 'SecurePassword123!', code: '123456'});Response
Section titled “Response”{ "statusCode": 200, "message": "Backup codes regenerated", "data": { "backup_codes": [ "a1b2c3d4e5", "f6g7h8i9j0", "k1l2m3n4o5", "p6q7r8s9t0", "u1v2w3x4y5", "z6a7b8c9d0", "e1f2g3h4i5", "j6k7l8m9n0", "o1p2q3r4s5", "t6u7v8w9x0" ] }}{ "statusCode": 400, "error": "INCORRECT_PASSWORD", "message": "Incorrect password"}| Error Code | Title | Description | Resolution |
|---|---|---|---|
VALIDATION_ERROR | Invalid input parameters | One or more request parameters failed validation | Check the error message for specific field requirements and correct your input |
MISSING_REQUIRED_FIELD | Required field missing | One or more required fields are missing from the request | Include all required fields as specified in the API documentation |
INCORRECT_PASSWORD | Incorrect password | The provided password does not match the account password | Verify your password and try again |
INVALID_OTP_CODE | Invalid OTP code | The provided 2FA code is incorrect or has expired | Generate a new code from your authenticator app and try again |
TWOFACTOR_NOT_ENABLED | 2FA not enabled | Two-factor authentication is not enabled for this account | Set up 2FA first using the setup endpoint |
{ "statusCode": 401, "error": "MISSING_TOKEN", "message": "Authentication token required"}| Error Code | Title | Description | Resolution |
|---|---|---|---|
MISSING_TOKEN | Authentication token missing | No authentication token was provided in the request | Include a valid JWT token in the Authorization header as Bearer <token> |
INVALID_TOKEN | Invalid authentication token | The provided authentication token is malformed or invalid | Obtain a new token by logging in again or using a valid auth token |
TOKEN_EXPIRED | Authentication token expired | The provided authentication token has expired | Obtain a new token by logging in again or refreshing your session |
INVALID_OTP_CODE | Invalid OTP code | The provided 2FA code is incorrect or has expired | Generate a new code from your authenticator app and try again |
AUTH_TOKEN_LINEAGE_REVOKED | Parent auth token no longer valid | The auth token was created by another auth token, and that token (or one above it) has been revoked, disabled or has expired, so this one no longer authenticates | Create a new auth token from a valid one, or sign in again |
EMAIL_NOT_VERIFIED | Email not verified | Returned by the login endpoint when the password is correct but the account’s email address has not been verified yet. It is only returned after the password has been checked. The response carries data.email so the client can offer a ‘resend verification email’ action without asking again. | Complete email verification by clicking the link sent to your inbox, or call /auth/resend-verification to receive a new link, or complete a password reset which also implicitly verifies the email. |
{ "statusCode": 403, "error": "INSUFFICIENT_PERMISSIONS", "message": "Insufficient permissions"}| Error Code | Title | Description | Resolution |
|---|---|---|---|
INSUFFICIENT_PERMISSIONS | Insufficient permissions | You do not have the required permissions to perform this action | Ask the resource owner to grant you access |
ACCOUNT_BANNED | Account banned | Your account has been banned and cannot access this resource | Contact support for information about your account status |
{ "statusCode": 409, "error": "Conflict", "message": "Account credentials changed during the request; re-authenticate and retry."}The account credentials this request was authorized against changed while it was in flight (a competing password change, emailed reset, logout or 2FA change). Nothing was written; re-authenticate and retry.
Validation Error: the request does not match the operation schema, or its JSON body cannot be parsed. message names each failing check, and data, when present, lists them one entry per failing field.
{ "statusCode": 422, "error": "Validation Error", "message": "Validation failed: /name must NOT have fewer than 3 characters"}{ "statusCode": 429, "error": "TWOFACTOR_RATE_LIMIT", "message": "Too many failed attempts. Account locked for 15 minutes.", "data": { "lockout_seconds": 900 }}| Error Code | Title | Description | Resolution |
|---|---|---|---|
TWOFACTOR_RATE_LIMIT | 2FA verification locked | Too many failed 2FA verification attempts. Account is temporarily locked | Wait for the lockout period to expire (15 minutes) before trying again |
PUT /api/v1/users/auth/2fa/token-gate
Section titled “PUT /api/v1/users/auth/2fa/token-gate”Enable or disable the OTP requirement for token mutation operations. Disabling requires both password and OTP.
PATCH on the same path is also accepted and behaves identically.
The SDK reaches this operation through two methods:
client.api.auth.twoFactor.enableTokenGate()sendsenabled: true.client.api.auth.twoFactor.disableTokenGate({ password, otp_code })sendsenabled: false.
Request Body
Section titled “Request Body”| Field | Type | Required | Description |
|---|---|---|---|
enabled | boolean | Yes | true to require OTP for token mutations (default), false to skip OTP gate. |
password | string | No | Required when setting enabled=false (security downgrade requires primary-factor reauth). |
otp_code | string | No | TOTP code or backup code. Required when setting enabled=false. |
curl -X PUT https://api.hoody.com/api/v1/users/auth/2fa/token-gate \ -H 'Authorization: Bearer <jwt_token>' \ -H 'Content-Type: application/json' \ -d '{ "enabled": true }'import { HoodyClient } from 'hoody-sdk';
const client = new HoodyClient({ baseURL: 'https://api.hoody.com', token: process.env.HOODY_TOKEN });await client.api.auth.twoFactor.enableTokenGate({});Response
Section titled “Response”{ "statusCode": 200, "message": "Token gate preference updated", "data": { "require_for_tokens": true }}{ "statusCode": 400, "error": "OTP_REQUIRED", "message": "2FA verification required for this operation"}| Error Code | Title | Description | Resolution |
|---|---|---|---|
OTP_REQUIRED | 2FA verification required | This operation requires 2FA verification because your account has 2FA enabled | Provide an otp_code field with a valid TOTP code or backup code |
TWOFACTOR_NOT_ENABLED | 2FA not enabled | Two-factor authentication is not enabled for this account | Set up 2FA first using the setup endpoint |
{ "statusCode": 401, "error": "INVALID_OTP_CODE", "message": "Invalid or expired 2FA code"}| Error Code | Title | Description | Resolution |
|---|---|---|---|
INVALID_OTP_CODE | Invalid OTP code | The provided 2FA code is incorrect or has expired | Generate a new code from your authenticator app and try again |
INCORRECT_PASSWORD | Incorrect password | The provided password does not match the account password | Verify your password and try again |
{ "statusCode": 409, "error": "Conflict", "message": "Account credentials changed during the request; re-authenticate and retry."}The account credentials this request was authorized against changed while it was in flight (a competing password change, emailed reset, logout or 2FA change). Nothing was written; re-authenticate and retry.
Validation Error: the request does not match the operation schema, or its JSON body cannot be parsed. message names each failing check, and data, when present, lists them one entry per failing field.
{ "statusCode": 422, "error": "Validation Error", "message": "Validation failed: /name must NOT have fewer than 3 characters"}{ "statusCode": 429, "error": "TWOFACTOR_RATE_LIMIT", "message": "Too many failed attempts. Account locked for 15 minutes.", "data": { "lockout_seconds": 900 }}| Error Code | Title | Description | Resolution |
|---|---|---|---|
TWOFACTOR_RATE_LIMIT | 2FA verification locked | Too many failed 2FA verification attempts. Account is temporarily locked | Wait for the lockout period to expire (15 minutes) before trying again |
DELETE /api/v1/users/auth/2fa
Section titled “DELETE /api/v1/users/auth/2fa”Disable 2FA for the account. Requires both current password and a valid OTP code (or backup code) for security. On success, all sessions are revoked and a fresh token / refreshToken pair is returned (the response carries data.token, data.refreshToken, and data.sessions_revoked: true).
Request Body
Section titled “Request Body”| Field | Type | Required | Description |
|---|---|---|---|
password | string | Yes | Current account password. |
code | string | Yes | 6-digit OTP code from authenticator app OR backup code. |
curl -X DELETE https://api.hoody.com/api/v1/users/auth/2fa \ -H 'Authorization: Bearer <jwt_token>' \ -H 'Content-Type: application/json' \ -d '{ "password": "SecurePassword123!", "code": "123456" }'import { HoodyClient } from 'hoody-sdk';
const client = new HoodyClient({ baseURL: 'https://api.hoody.com', token: process.env.HOODY_TOKEN });await client.api.auth.twoFactor.disable({ password: 'SecurePassword123!', code: '123456'});Response
Section titled “Response”{ "statusCode": 200, "message": "2FA successfully disabled. All other sessions have been signed out.", "data": { "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", "sessions_revoked": true }}{ "statusCode": 400, "error": "INCORRECT_PASSWORD", "message": "Incorrect password"}| Error Code | Title | Description | Resolution |
|---|---|---|---|
VALIDATION_ERROR | Invalid input parameters | One or more request parameters failed validation | Check the error message for specific field requirements and correct your input |
MISSING_REQUIRED_FIELD | Required field missing | One or more required fields are missing from the request | Include all required fields as specified in the API documentation |
INCORRECT_PASSWORD | Incorrect password | The provided password does not match the account password | Verify your password and try again |
INVALID_OTP_CODE | Invalid OTP code | The provided 2FA code is incorrect or has expired | Generate a new code from your authenticator app and try again |
INVALID_BACKUP_CODE | Invalid backup code | The provided backup code is incorrect or has already been used | Verify the backup code is correct and has not been used previously |
TWOFACTOR_NOT_ENABLED | 2FA not enabled | Two-factor authentication is not enabled for this account | Set up 2FA first using the setup endpoint |
{ "statusCode": 401, "error": "MISSING_TOKEN", "message": "Authentication token required"}| Error Code | Title | Description | Resolution |
|---|---|---|---|
MISSING_TOKEN | Authentication token missing | No authentication token was provided in the request | Include a valid JWT token in the Authorization header as Bearer <token> |
INVALID_TOKEN | Invalid authentication token | The provided authentication token is malformed or invalid | Obtain a new token by logging in again or using a valid auth token |
TOKEN_EXPIRED | Authentication token expired | The provided authentication token has expired | Obtain a new token by logging in again or refreshing your session |
INVALID_OTP_CODE | Invalid OTP code | The provided 2FA code is incorrect or has expired | Generate a new code from your authenticator app and try again |
INVALID_BACKUP_CODE | Invalid backup code | The provided backup code is incorrect or has already been used | Verify the backup code is correct and has not been used previously |
AUTH_TOKEN_LINEAGE_REVOKED | Parent auth token no longer valid | The auth token was created by another auth token, and that token (or one above it) has been revoked, disabled or has expired, so this one no longer authenticates | Create a new auth token from a valid one, or sign in again |
EMAIL_NOT_VERIFIED | Email not verified | Returned by the login endpoint when the password is correct but the account’s email address has not been verified yet. It is only returned after the password has been checked. The response carries data.email so the client can offer a ‘resend verification email’ action without asking again. | Complete email verification by clicking the link sent to your inbox, or call /auth/resend-verification to receive a new link, or complete a password reset which also implicitly verifies the email. |
{ "statusCode": 403, "error": "INSUFFICIENT_PERMISSIONS", "message": "Insufficient permissions"}| Error Code | Title | Description | Resolution |
|---|---|---|---|
INSUFFICIENT_PERMISSIONS | Insufficient permissions | You do not have the required permissions to perform this action | Ask the resource owner to grant you access |
ACCOUNT_BANNED | Account banned | Your account has been banned and cannot access this resource | Contact support for information about your account status |
{ "statusCode": 409, "error": "Conflict", "message": "Account credentials changed during the request; re-authenticate and retry."}The account credentials this request was authorized against changed while it was in flight (a competing password change, emailed reset, logout or 2FA change). Nothing was written; re-authenticate and retry.
Validation Error: the request does not match the operation schema, or its JSON body cannot be parsed. message names each failing check, and data, when present, lists them one entry per failing field.
{ "statusCode": 422, "error": "Validation Error", "message": "Validation failed: /name must NOT have fewer than 3 characters"}{ "statusCode": 429, "error": "TWOFACTOR_RATE_LIMIT", "message": "Too many failed attempts. Account locked for 15 minutes.", "data": { "lockout_seconds": 900 }}| Error Code | Title | Description | Resolution |
|---|---|---|---|
TWOFACTOR_RATE_LIMIT | 2FA verification locked | Too many failed 2FA verification attempts. Account is temporarily locked | Wait for the lockout period to expire (15 minutes) before trying again |