Authentication
Section titled “Authentication”The Hoody API authenticates users with short-lived JWT access tokens and longer-lived refresh tokens, and supports TOTP-based two-factor authentication. This page covers every authentication surface: PKCE OAuth with GitHub and Google, the RFC-8628-inspired device authorization flow used by the CLI and SSH agents, email-and-password login, email verification, password recovery, session lifecycle (refresh, logout, intent cancellation), current-user introspection, the full 2FA lifecycle (setup, verification, backup-code rotation, token gate, disable), and audience-bound identity-claim issuance. All examples target https://api.hoody.com.
Authenticated requests send Authorization: Bearer <token> (or the long-lived auth token in the same header). Token-gated 2FA actions also accept Authorization: Bearer <refreshToken> and a body code field.
Public endpoints
Section titled “Public endpoints”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 API responses (the X-Hoody-Signature header), identity claims issued at login, and container authorization claims. The keys[] array is a rotation window: it carries the next prepublished key and the previously retained key alongside the currently active one (matched by active_kid), so verifiers can still validate signatures that were issued before the new key became active. Always look the key up by kid rather than pinning to a fixed key; if a signature or claim references an unknown kid, re-fetch this endpoint. No authentication is required; the endpoint is intentionally public.
curl -s 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();This endpoint takes no parameters.
{ "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": "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 } }}{ "statusCode": 503, "error": "SIGNING_NOT_CONFIGURED", "message": "Response signing is not configured on this API instance"}GET /api/v1/auth/available-regions
Section titled “GET /api/v1/auth/available-regions”Returns regions where free-tier servers exist, with a boolean availability flag for each. Public, no authentication required.
curl -s 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.authentication.getAvailableRegions();This endpoint takes no parameters.
{ "statusCode": 200, "data": { "regions": [ { "region": "eu-west", "country": "DE", "city": "Frankfurt", "available": true }, { "region": "us-east", "country": "US", "city": "Ashburn", "available": false }, { "region": "ap-south", "country": "SG", "city": "Singapore", "available": true } ] }}GET /api/v1/auth/config
Section titled “GET /api/v1/auth/config”Returns the public sign-in configuration used to drive the OAuth and password flows (for example, which identity providers are enabled and which flows are exposed to the SPA).
curl -s 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.authentication.getOAuthConfig();This endpoint takes no parameters.
The default response shape is returned.
Identity claims
Section titled “Identity claims”An identity claim is an ED25519-signed credential that proves “Hoody authenticated this user” to systems outside Hoody. Verifiers validate the signature offline against the public keys published at GET /api/v1/meta/public-key — no round trip to Hoody is needed after the initial key fetch. Claims are returned by:
POST /api/v1/users/auth/login(defaulttokensmode — see Login)POST /api/v1/users/auth/2fa/verify(defaulttokensmode — see Verify 2FA Code During Login)POST /api/v1/auth/verify-email(whenresponse_mode=tokensand signing is configured — see Verify email address)POST /api/v1/auth/device/token(device-flow token mint)POST /api/v1/users/auth/identity-claim(audience-bound re-issuance)
Claims are omitted when response_mode=intent (the hosted auth UI hands off a single-use auth_intent_token to the SPA via PKCE exchange), and omitted when HOODY_SIGNING_PRIVATE_KEY is not configured on the server. In that case the response just lacks the identity_claim field — call /api/v1/meta/public-key once at startup to determine whether signing is available (a 200 response is the signal).
Bundle format
| Field | Type | Description |
|---|---|---|
kid | string | Key identifier; look up the matching key in GET /api/v1/meta/public-key keys[] |
payload_b64 | string | base64url-encoded UTF-8 JSON containing the payload below |
signature_hex | string | ED25519 detached signature over the UTF-8 bytes of payload_b64 (128 hex chars = 64 bytes) |
Payload (payload_b64 decoded)
| Field | Type | Description |
|---|---|---|
claim_type | string | Always "identity" |
iss | string | Always "hoody-api" |
sub | string | User ID (24-character hex) |
username | string | The authenticated username at the time of issue |
type | string | "user" or "admin" |
iat | integer | Issued-at (Unix seconds) |
exp | integer | Expiry (Unix seconds); default ~30 days |
kid | string | The signing key id used; must equal the bundle kid |
aud | string | Optional audience binding (only present on audience-bound re-issued claims) |
Mandatory verification checks
A verifier MUST perform all of the following before trusting a claim:
- Look the
kidup inGET /api/v1/meta/public-keykeys[]and obtain the 32-byte ED25519 public key. If thekidis not inkeys[], re-fetch the endpoint (key rotation) and try again; reject if still missing. - Verify the signature:
nacl.sign.detached.verify(UTF8(payload_b64), hexToBytes(signature_hex), publicKey)returnstrue. The signed bytes are the UTF-8 bytes of thepayload_b64string itself, not the decoded JSON. payload.claim_type === "identity".payload.iss === "hoody-api".payload.exp > nowandpayload.exp > payload.iat.payload.iat <= now + 300(clock skew tolerance).payload.kid === bundle.kidand audience semantics are strict both ways: ifpayload.audis present, the verifier MUST be that audience AND the verifier’s own identifier MUST be the audience; ifpayload.audis absent, the verifier MUST NOT be audience-aware (otherwise reject).
const crypto = require('crypto');
// Public key MUST come from GET /api/v1/meta/public-key keys[] looked up by kid.// ED25519 SPKI prefix for 32-byte raw keys.const SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex');
function hexToBytes(hex) { return Buffer.from(hex, 'hex');}
function rawPubkeyToSpki(raw32) { return Buffer.concat([SPKI_PREFIX, raw32]);}
function verifyIdentityClaim({ kid, payload_b64, signature_hex }, publicKeyHex, expectedAudience /* or null */) { // 1. kid -> 32-byte raw ED25519 public key const rawPub = hexToBytes(publicKeyHex); if (rawPub.length !== 32) throw new Error('bad pubkey length');
// 2. signature over UTF-8 bytes of payload_b64 const ok = crypto.verify( null, Buffer.from(payload_b64, 'utf8'), { key: rawPubkeyToSpki(rawPub), dsaEncoding: 'ieee-p1363' }, hexToBytes(signature_hex) ); if (!ok) throw new Error('bad signature');
// 3-6. payload shape const payload = JSON.parse(Buffer.from(payload_b64, 'base64url').toString('utf8')); if (payload.claim_type !== 'identity') throw new Error('bad claim_type'); if (payload.iss !== 'hoody-api') throw new Error('bad iss'); const now = Math.floor(Date.now() / 1000); if (!(payload.exp > now)) throw new Error('expired'); if (!(payload.exp > payload.iat)) throw new Error('exp <= iat'); if (!(payload.iat <= now + 300)) throw new Error('iat in the future');
// 7. kid match + strict both-ways audience semantics if (payload.kid !== kid) throw new Error('kid mismatch'); if (payload.aud) { if (!expectedAudience || payload.aud !== expectedAudience) throw new Error('aud mismatch'); } else if (expectedAudience) { throw new Error('audience required by verifier but claim is audience-unbound'); } return payload;}
// Example claim bundle (decodable; signature is illustrative)const claim = { kid: 'v1', payload_b64: 'eyJzdWIiOiI1MDdmMWY3N2JjZjg2Y2Q3OTk0MzkwMTEiLCJ1c2VybmFtZSI6ImFsaWNlIiwidHlwZSI6InVzZXIiLCJpYXQiOjE3NDEyOTAwMDAsImV4cCI6MTc0Mzg4MjAwMCwia2lkIjoidjEifQ', signature_hex: 'a'.repeat(128),};const nacl = require('tweetnacl');
function verifyIdentityClaim({ kid, payload_b64, signature_hex }, publicKey /* Uint8Array(32) */, expectedAudience) { const ok = nacl.sign.detached.verify( new TextEncoder().encode(payload_b64), Uint8Array.from(Buffer.from(signature_hex, 'hex')), publicKey ); if (!ok) throw new Error('bad signature');
const payload = JSON.parse(Buffer.from(payload_b64, 'base64url').toString('utf8')); const now = Math.floor(Date.now() / 1000); if (payload.claim_type !== 'identity') throw new Error('bad claim_type'); if (payload.iss !== 'hoody-api') throw new Error('bad iss'); if (!(payload.exp > now && payload.exp > payload.iat)) throw new Error('expired'); if (!(payload.iat <= now + 300)) throw new Error('iat in the future'); if (payload.kid !== kid) throw new Error('kid mismatch'); if (payload.aud) { if (!expectedAudience || payload.aud !== expectedAudience) throw new Error('aud mismatch'); } else if (expectedAudience) { throw new Error('audience required by verifier but claim is audience-unbound'); } return payload;}Gating patterns for proxied applications
There are three ways to gate a proxied application on Hoody authentication, in order of preference:
- Native
hoody-identitypermission group. Add thehoody-identity.authenticationpermission to the app’s proxy permissions; the edge enforces it before the request reaches the app. See Proxy permissions: hoody-identity. - App-level gate via a NON-RESERVED header. The edge strips any header in the
X-Hoody-Identity-*namespace before the request reaches the application — a claim sent there never arrives. Instead, convention isX-Hoody-Claim: <payload_b64>.<signature_hex>carrying a pinnedkid. The app looks up the kid in its cached/api/v1/meta/public-keyresponse and runs the seven checks above. - Proxy hook. For per-route policy that isn’t a single permission group, attach an
identity_claim_auth_gatehook that performs the same verification on the claim header. See Proxy hooks: identity_claim_auth_gate — that recipe likewise uses the non-reservedx-hoody-claimheader.
For container-scoped claims (programs running inside a container that need to authenticate against a third party), see Container claims on the Containers API.
Sign up
Section titled “Sign up”POST /api/v1/auth/signup
Section titled “POST /api/v1/auth/signup”Create a new account with email and password. A verification email is sent; the account is not active until the email is verified.
curl -X POST https://api.hoody.com/api/v1/auth/signup \ -H "Content-Type: application/json" \ -d '{ "email": "alice@example.com", "password": "CorrectHorseBatteryStaple!42", "region": "eu-west" }'import { HoodyClient } from 'hoody-sdk';
const client = new HoodyClient({ baseURL: 'https://api.hoody.com', token: process.env.HOODY_TOKEN });
await client.api.authentication.signup({ email: 'alice@example.com', password: 'CorrectHorseBatteryStaple!42', region: 'eu-west',});This endpoint takes no path/query/header parameters.
Request body
| Name | Type | Required | Description |
|---|---|---|---|
email | string | Yes | Email address for the new account (max 255 chars). |
password | string | Yes | Password (min 12, max 128 chars; must include uppercase, lowercase, number, and special char). |
region | string | No | Optional preferred server region (e.g. eu-west). Auto-assigned by GeoIP if omitted. |
invite_code | string | No | Optional invite code from the signup link. Memorized (hash-only) and applied after email verification. |
client | string | No | Source channel for analytics: web | ssh | webssh | cli | sdk | agent. |
{ "statusCode": 200, "message": "Verification email sent", "data": { "email": "alice@example.com" }}{ "statusCode": 400, "message": "Password does not meet complexity requirements"}{ "statusCode": 403, "message": "Sign-up is currently disabled"}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. The default response returns full login credentials (and a identity claim when response_mode=tokens and signing is configured). 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.
curl -X POST https://api.hoody.com/api/v1/auth/verify-email \ -H "Content-Type: application/json" \ -d '{ "token": "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234", "response_mode": "tokens" }'import { HoodyClient } from 'hoody-sdk';
const client = new HoodyClient({ baseURL: 'https://api.hoody.com', token: process.env.HOODY_TOKEN });
await client.api.authentication.verifyEmail({ token: 'abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234', response_mode: 'tokens',});This endpoint takes no path/query/header parameters.
Request body
| Name | Type | Required | Description |
|---|---|---|---|
token | string | Yes | Verification token from the email link (64 chars). |
response_mode | string | No | tokens (default — returns access/refresh tokens) or intent (returns an opaque auth_intent_token for PKCE exchange). |
code_challenge | string | No | PKCE code_challenge (base64url SHA-256 of code_verifier). Required when response_mode=intent. |
client | string | No | Source channel for analytics. |
{ "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": "eyJzdWIiOiI1MDdmMWY3N2JjZjg2Y2Q3OTk0MzkwMTEiLCJ1c2VybmFtZSI6ImFsaWNlIiwidHlwZSI6InVzZXIiLCJpYXQiOjE3NDEyOTAwMDAsImV4cCI6MTc0Mzg4MjAwMCwia2lkIjoidjEifQ", "signature_hex": "abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef12" }, "user": { "id": "507f1f77bcf86cd799439011", "username": "alice", "email": "alice@example.com", "is_admin": false, "email_verified": true, "signup_method": "email", "created_at": "2025-01-15T10:30:00.000Z", "updated_at": "2025-01-15T10:30:00.000Z" } }}{ "statusCode": 400, "message": "Invalid or expired verification token"}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.
curl -X POST https://api.hoody.com/api/v1/auth/resend-verification \ -H "Content-Type: application/json" \ -d '{ "email": "alice@example.com" }'import { HoodyClient } from 'hoody-sdk';
const client = new HoodyClient({ baseURL: 'https://api.hoody.com', token: process.env.HOODY_TOKEN });
await client.api.authentication.resendVerification({ email: 'alice@example.com',});This endpoint takes no path/query/header parameters.
Request body
| Name | Type | Required | Description |
|---|---|---|---|
email | string | Yes | Email address to resend verification to (max 255 chars). |
{ "statusCode": 200, "message": "If an account exists for that email, a verification link has been sent"}Password recovery
Section titled “Password recovery”POST /api/v1/auth/forgot-password
Section titled “POST /api/v1/auth/forgot-password”Request a password reset email. Always returns success to prevent email enumeration.
curl -X POST https://api.hoody.com/api/v1/auth/forgot-password \ -H "Content-Type: application/json" \ -d '{ "email": "alice@example.com" }'import { HoodyClient } from 'hoody-sdk';
const client = new HoodyClient({ baseURL: 'https://api.hoody.com', token: process.env.HOODY_TOKEN });
await client.api.authentication.forgotPassword({ email: 'alice@example.com',});This endpoint takes no path/query/header parameters.
Request body
| Name | Type | Required | Description |
|---|---|---|---|
email | string | Yes | Email address associated with the account. |
{ "statusCode": 200, "message": "If an account exists for that email, a reset link has been sent"}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.
curl -X POST https://api.hoody.com/api/v1/auth/reset-password \ -H "Content-Type: application/json" \ -d '{ "token": "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234", "password": "NewSecurePass!42" }'import { HoodyClient } from 'hoody-sdk';
const client = new HoodyClient({ baseURL: 'https://api.hoody.com', token: process.env.HOODY_TOKEN });
await client.api.authentication.resetPassword({ token: 'abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234', password: 'NewSecurePass!42',});This endpoint takes no path/query/header parameters.
Request body
| Name | Type | Required | Description |
|---|---|---|---|
token | string | Yes | Password reset token from the email link (64 chars). |
password | string | Yes | New password (min 12, max 128 chars). |
{ "statusCode": 200, "message": "Password reset successful"}{ "statusCode": 400, "message": "Invalid or expired reset token"}Login and session management
Section titled “Login and session management”POST /api/v1/users/auth/login
Section titled “POST /api/v1/users/auth/login”Authenticate with username/email + password to receive a JWT access token (expires in 1 day) and a refresh token (expires in 7 days). Use the access token in the Authorization header for subsequent requests: Authorization: Bearer <token>. The response includes an identity claim when HOODY_SIGNING_PRIVATE_KEY is configured; in intent mode the response instead carries an opaque auth_intent_token for PKCE exchange (hosted auth UI only). If 2FA is enabled, returns requires_2fa: true plus a temp_token for verify-setup.
curl -X POST https://api.hoody.com/api/v1/users/auth/login \ -H "Content-Type: application/json" \ -d '{ "username": "alice", "password": "CorrectHorseBatteryStaple!42" }'import { HoodyClient } from 'hoody-sdk';
const client = new HoodyClient({ baseURL: 'https://api.hoody.com', token: process.env.HOODY_TOKEN });
await client.api.authentication.login({ username: 'alice', password: 'CorrectHorseBatteryStaple!42',});This endpoint takes no path/query/header parameters.
Request body
| Name | Type | Required | Description |
|---|---|---|---|
username | string | Conditional | Username (3-50 chars; alphanumeric, underscores, hyphens). |
email | string | Conditional | Email address (max 255 chars; alternative to username). |
password | string | Yes | Account password (8-128 chars; must include uppercase, lowercase, and number). |
response_mode | string | No | tokens (default) or intent (PKCE exchange). |
client | string | No | Source channel for analytics. |
code_challenge | string | No | PKCE code_challenge. Required when response_mode=intent. |
{ "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": "eyJzdWIiOiI1MDdmMWY3N2JjZjg2Y2Q3OTk0MzkwMTEiLCJ1c2VybmFtZSI6ImFsaWNlIiwidHlwZSI6InVzZXIiLCJpYXQiOjE3NDEyOTAwMDAsImV4cCI6MTc0Mzg4MjAwMCwia2lkIjoidjEifQ", "signature_hex": "abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef12" }, "client_ip": "192.168.1.100", "recent_login_ips": [ { "ip": "192.168.1.100", "timestamp": "2025-01-15T10:30:00.000Z" }, { "ip": "10.0.0.5", "timestamp": "2025-01-14T09:15:00.000Z" } ], "auth_token_count": 2, "user": { "id": "507f1f77bcf86cd799439011", "username": "alice", "alias": "Alice", "email": "alice@example.com", "is_admin": false, "is_banned": false, "metadata": {}, "created_at": "2024-12-01T10:00:00.000Z", "updated_at": "2025-01-15T10:30:00.000Z" } }}{ "statusCode": 400, "error": "MISSING_REQUIRED_FIELD", "message": "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. |
MISSING_REQUIRED_FIELD | Required field missing | One or more required fields are missing. | Include password (and either username or email). |
{ "statusCode": 401, "error": "INVALID_CREDENTIALS", "message": "Invalid email 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 /auth/forgot-password. |
EMAIL_NOT_VERIFIED | Email not verified | The password is correct but the email address has not been verified. Response carries data.email so the client can offer a resend-verification CTA. | Click the verification link, call /auth/resend-verification, or complete a password reset which also implicitly verifies the email. |
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 a new refresh token. Send the refresh token in the Authorization header: Authorization: Bearer <refreshToken>.
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.authentication.refreshToken({ refreshToken: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...',});This endpoint takes no path/query/header parameters.
Request body
| Name | Type | Required | Description |
|---|---|---|---|
refreshToken | string | Yes | Valid refresh token from a previous login or refresh. |
{ "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": "TOKEN_EXPIRED", "message": "Token has expired"}| Error Code | Title | Description | Resolution |
|---|---|---|---|
MISSING_TOKEN | Authentication token missing | No authentication token was provided. | Include a valid JWT token in the Authorization header. |
INVALID_TOKEN | Invalid authentication token | The provided token is malformed or invalid. | Obtain a new token by logging in again. |
TOKEN_EXPIRED | Authentication token expired | The provided token has expired. | Refresh the session or log in again. |
POST /api/v1/users/auth/logout
Section titled “POST /api/v1/users/auth/logout”Log out the current user. Creates an audit log entry. In a stateless JWT setup, the client should also discard the token locally. This endpoint works even for banned users (read-only access).
curl -X POST https://api.hoody.com/api/v1/users/auth/logout \ -H "Authorization: Bearer $HOODY_JWT"import { HoodyClient } from 'hoody-sdk';
const client = new HoodyClient({ baseURL: 'https://api.hoody.com', token: process.env.HOODY_TOKEN });
await client.api.authentication.logout();This endpoint takes no parameters and accepts no request body.
{ "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. | Include a valid JWT token in the Authorization header. |
INVALID_TOKEN | Invalid authentication token | The provided token is malformed or invalid. | Obtain a new token by logging in again. |
TOKEN_EXPIRED | Authentication token expired | The provided token has expired. | Refresh the session or log in again. |
POST /api/v1/auth/intent/cancel
Section titled “POST /api/v1/auth/intent/cancel”Cancel a pending OAuth AuthIntent or 2FA temp_token. Idempotent. Used by the handoff page when the user dismisses the confirmation.
curl -X POST https://api.hoody.com/api/v1/auth/intent/cancel \ -H "Authorization: Bearer $HOODY_INTENT_TOKEN"import { HoodyClient } from 'hoody-sdk';
const client = new HoodyClient({ baseURL: 'https://api.hoody.com', token: process.env.HOODY_TOKEN });
await client.api.authentication.oauthCancelIntent();This endpoint takes no parameters and accepts no request body.
The default no-content response is returned.
Current user
Section titled “Current user”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 HTTP Basic authentication. When authenticated with an auth token, the response includes data.auth_token introspection details (permissions and realm restrictions). This endpoint works even for banned users (read-only access). 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.
curl -s https://api.hoody.com/api/v1/users/auth/me \ -H "Authorization: Bearer $HOODY_JWT"import { HoodyClient } from 'hoody-sdk';
const client = new HoodyClient({ baseURL: 'https://api.hoody.com', token: process.env.HOODY_TOKEN });
await client.api.authentication.getCurrentUser();This endpoint takes no parameters.
{ "statusCode": 200, "message": "Current user retrieved successfully", "data": { "id": "507f1f77bcf86cd799439011", "username": "alice", "email": "alice@example.com", "alias": "Alice", "public_key": "a1b2c3d4e5f6789012345678901234567890abcdefabcdefabcdefabcdef1234", "is_admin": false, "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" }, "metadata": {}, "created_at": "2024-12-01T10:00:00.000Z", "updated_at": "2025-01-15T10: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. | Include a valid JWT token in the Authorization header. |
INVALID_TOKEN | Invalid authentication token | The provided token is malformed or invalid. | Obtain a new token by logging in again. |
TOKEN_EXPIRED | Authentication token expired | The provided token has expired. | Refresh the session or log in again. |
GET /api/v1/users/me
Section titled “GET /api/v1/users/me”Alias of GET /api/v1/users/auth/me. Same response shape and error model.
curl -s https://api.hoody.com/api/v1/users/me \ -H "Authorization: Bearer $HOODY_JWT"import { HoodyClient } from 'hoody-sdk';
const client = new HoodyClient({ baseURL: 'https://api.hoody.com', token: process.env.HOODY_TOKEN });
await client.api.authentication.getCurrentUserAlias();This endpoint takes no parameters.
{ "statusCode": 200, "message": "Current user retrieved successfully", "data": { "id": "507f1f77bcf86cd799439011", "username": "alice", "email": "alice@example.com", "alias": "Alice", "is_admin": false, "is_banned": false, "created_at": "2024-12-01T10:00:00.000Z", "updated_at": "2025-01-15T10:30:00.000Z" }}{ "statusCode": 401, "error": "MISSING_TOKEN", "message": "Authentication token required"}| Error Code | Title | Description | Resolution |
|---|---|---|---|
MISSING_TOKEN | Authentication token missing | No authentication token was provided. | Include a valid JWT token in the Authorization header. |
INVALID_TOKEN | Invalid authentication token | The provided token is malformed or invalid. | Obtain a new token by logging in again. |
TOKEN_EXPIRED | Authentication token expired | The provided token has expired. | Refresh the session or log in again. |
OAuth (browser)
Section titled “OAuth (browser)”POST /api/v1/auth/authorize
Section titled “POST /api/v1/auth/authorize”Begin a PKCE OAuth authorization. Registers a PKCE authorization request (code challenge + redirect URI) and returns a state JWT to drive the browser sign-in flow.
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/callback" }'import { HoodyClient } from 'hoody-sdk';
const client = new HoodyClient({ baseURL: 'https://api.hoody.com', token: process.env.HOODY_TOKEN });
await client.api.authentication.oauthAuthorize({ code_challenge: 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM', redirect_uri: 'https://app.example.com/callback',});This endpoint takes no path/query/header parameters.
Request body
| Name | Type | Required | Description |
|---|---|---|---|
code_challenge | string | Yes | PKCE code_challenge (base64url SHA-256 of code_verifier; 43 chars). |
redirect_uri | string | Yes | Frontend URL to redirect to after OAuth completes (must be https://). |
The default response shape is returned.
POST /api/v1/auth/exchange
Section titled “POST /api/v1/auth/exchange”Complete the PKCE authorization-code flow by exchanging an authorization code and its code verifier for authentication tokens.
curl -X POST https://api.hoody.com/api/v1/auth/exchange \ -H "Content-Type: application/json" \ -d '{ "code": "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234", "code_verifier": "KbfeC6xCSz8VbEm7o7nK5sXpAhhVxqj1pC8jMlA2oF3sLe1cN0dPqRsTuVwXyZ", "redirect_uri": "https://app.example.com/callback" }'import { HoodyClient } from 'hoody-sdk';
const client = new HoodyClient({ baseURL: 'https://api.hoody.com', token: process.env.HOODY_TOKEN });
await client.api.authentication.oauthExchange({ code: 'abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234', code_verifier: 'KbfeC6xCSz8VbEm7o7nK5sXpAhhVxqj1pC8jMlA2oF3sLe1cN0dPqRsTuVwXyZ', redirect_uri: 'https://app.example.com/callback',});This endpoint takes no path/query/header parameters.
Request body
| Name | Type | Required | Description |
|---|---|---|---|
code | string | Yes | Authorization code (64 hex chars). |
code_verifier | string | Yes | PKCE code_verifier (43-128 chars). |
redirect_uri | string | Yes | The same redirect URI used in the authorization request. |
The default response shape is returned.
GET /api/v1/auth/github
Section titled “GET /api/v1/auth/github”Redirects the browser to GitHub for OAuth authentication. Browser-only endpoint. PKCE is required post-migration; code_challenge is a required query parameter.
curl -i https://api.hoody.com/api/v1/auth/github \ -G \ --data-urlencode "client=web" \ --data-urlencode "intent=login" \ --data-urlencode "redirect_uri=https://app.example.com/callback" \ --data-urlencode "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.authentication.githubOAuthRedirect({ client: 'web', intent: 'login', redirect_uri: 'https://app.example.com/callback', code_challenge: 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM',});Parameters
Section titled “Parameters”| Name | In | Type | Required | Description |
|---|---|---|---|---|
client | query | string | No | Source channel for analytics: web | ssh | webssh | cli | sdk | agent. Folded into the signed OAuth state. |
intent | query | string | No | OAuth intent: login (default). star_check is accepted but ignored (retired). |
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). |
invite_code | query | string | No | Optional invite code captured from the signup link. |
A redirect to the GitHub OAuth provider is returned.
GET /api/v1/auth/github/callback
Section titled “GET /api/v1/auth/github/callback”Handles the GitHub OAuth callback. Browser-only endpoint.
curl -i https://api.hoody.com/api/v1/auth/github/callback \ -G \ --data-urlencode "code=abcd1234github_code" \ --data-urlencode "state=eyJhbGciOi..."import { HoodyClient } from 'hoody-sdk';
const client = new HoodyClient({ baseURL: 'https://api.hoody.com', token: process.env.HOODY_TOKEN });
await client.api.authentication.githubOAuthCallback({ code: 'abcd1234github_code', state: 'eyJhbGciOi...',});Parameters
Section titled “Parameters”| Name | In | Type | Required | Description |
|---|---|---|---|---|
code | query | string | No | OAuth code returned by GitHub. |
state | query | string | Yes | Signed OAuth state JWT returned 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-side error description. |
error_uri | query | string | No | Provider-side error URI. |
A redirect to the frontend after OAuth completes is returned.
GET /api/v1/auth/google
Section titled “GET /api/v1/auth/google”Redirects the browser to Google for OAuth authentication. Browser-only endpoint. PKCE is required post-migration; code_challenge is a required query parameter.
curl -i https://api.hoody.com/api/v1/auth/google \ -G \ --data-urlencode "client=web" \ --data-urlencode "redirect_uri=https://app.example.com/callback" \ --data-urlencode "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.authentication.googleOAuthRedirect({ client: 'web', redirect_uri: 'https://app.example.com/callback', code_challenge: 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM',});Parameters
Section titled “Parameters”| Name | In | Type | Required | Description |
|---|---|---|---|---|
client | query | string | No | Source channel for analytics. |
redirect_uri | query | string | Yes | Frontend URL to redirect to after OAuth completes. |
code_challenge | query | string | Yes | PKCE code_challenge (base64url SHA-256 of code_verifier). |
invite_code | query | string | No | Optional invite code captured from the signup link. |
A redirect to the Google OAuth provider is returned.
GET /api/v1/auth/google/callback
Section titled “GET /api/v1/auth/google/callback”Handles the Google OAuth callback. Browser-only endpoint.
curl -i https://api.hoody.com/api/v1/auth/google/callback \ -G \ --data-urlencode "code=abcd1234google_code" \ --data-urlencode "state=eyJhbGciOi..."import { HoodyClient } from 'hoody-sdk';
const client = new HoodyClient({ baseURL: 'https://api.hoody.com', token: process.env.HOODY_TOKEN });
await client.api.authentication.googleOAuthCallback({ code: 'abcd1234google_code', state: 'eyJhbGciOi...',});Parameters
Section titled “Parameters”| Name | In | Type | Required | Description |
|---|---|---|---|---|
code | query | string | No | OAuth code returned by Google. |
state | query | string | Yes | Signed OAuth state JWT returned by /auth/google. |
error | query | string | No | Provider-side failure code (e.g. access_denied). |
error_description | query | string | No | Provider-side error description. |
error_uri | query | string | No | Provider-side error URI. |
A redirect to the frontend after OAuth completes is returned.
POST /api/v1/auth/launch/initiate
Section titled “POST /api/v1/auth/launch/initiate”Initiate OAuth popup-handoff launch. 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.
curl -X POST https://api.hoody.com/api/v1/auth/launch/initiate \ -H "Content-Type: application/json" \ -d '{ "provider": "github", "code_challenge": "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", "state_id": "12345678-90ab-4cde-9f01-23456789abcd" }'import { HoodyClient } from 'hoody-sdk';
const client = new HoodyClient({ baseURL: 'https://api.hoody.com', token: process.env.HOODY_TOKEN });
await client.api.authentication.oauthLaunchInitiate({ provider: 'github', code_challenge: 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM', state_id: '12345678-90ab-4cde-9f01-23456789abcd',});This endpoint takes no path/query/header parameters.
Request body
| Name | Type | Required | Description |
|---|---|---|---|
provider | string | Yes | OAuth provider: github | google. |
client | string | No | Source channel for analytics. |
code_challenge | string | Yes | PKCE code_challenge (43 chars). |
state_id | string | Yes | Per-attempt UUID v4 — plumbed through state JWT, cookie name, fragment, message filter. |
{ "statusCode": 200, "data": { "launch_url": "https://api.hoody.com/api/v1/auth/launch/start?ticket=..." }}GET /api/v1/auth/launch/start
Section titled “GET /api/v1/auth/launch/start”Consumes the launch ticket atomically and runs the existing OAuth redirect flow. Sets Referrer-Policy: no-referrer.
curl -i https://api.hoody.com/api/v1/auth/launch/start \ -G \ --data-urlencode "ticket=abcd1234launch_ticket"import { HoodyClient } from 'hoody-sdk';
const client = new HoodyClient({ baseURL: 'https://api.hoody.com', token: process.env.HOODY_TOKEN });
await client.api.authentication.oauthLaunchStart({ ticket: 'abcd1234launch_ticket' });Parameters
Section titled “Parameters”| Name | In | Type | Required | Description |
|---|---|---|---|---|
ticket | query | string | Yes | One-shot ticket from /launch/initiate response. |
A redirect into the PKCE-protected OAuth flow is returned.
{ "statusCode": 410, "error": "LAUNCH_TICKET_INVALID", "message": "Launch ticket is invalid, expired, or already consumed"}Device authorization flow
Section titled “Device authorization flow”The RFC-8628-inspired device flow lets a CLI or SSH agent prompt the user to authorize the device in a browser. Lifecycle errors are nested under data, not at the response status level.
POST /api/v1/auth/device/code
Section titled “POST /api/v1/auth/device/code”Start a device authorization flow. Issues a device_code (polled by the CLI) and a short hand-typeable user_code (shown to the human). Public, no authentication.
curl -X POST https://api.hoody.com/api/v1/auth/device/code \ -H "Content-Type: application/json" \ -d '{ "client_name": "Hoody CLI", "client": "cli" }'import { HoodyClient } from 'hoody-sdk';
const client = new HoodyClient({ baseURL: 'https://api.hoody.com', token: process.env.HOODY_TOKEN });
await client.api.authentication.oauthDeviceCode({ client_name: 'Hoody CLI', client: 'cli',});This endpoint takes no path/query/header parameters.
Request body
| Name | Type | Required | Description |
|---|---|---|---|
client_name | string | No | Shown on the verification page as “X is requesting access” (max 64 chars). |
client | string | No | Source channel for analytics. |
code_challenge | string | No | Optional PKCE on the device flow itself; if present, the poll REQUIRES the verifier. |
{ "statusCode": 200, "data": { "device_code": "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234", "user_code": "ABCD-1234", "verification_uri": "https://api.hoody.com/device", "verification_uri_complete": "https://api.hoody.com/device?code=ABCD-1234", "interval": 5, "expires_in": 900 }}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 + coarse status.
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.authentication.oauthDeviceVerifyCode({ user_code: 'ABCD-1234',});This endpoint takes no path/query/header parameters.
Request body
| Name | Type | Required | Description |
|---|---|---|---|
user_code | string | Yes | The XXXX-XXXX user code (dashes optional; max 16 chars). |
{ "statusCode": 200, "data": { "status": "pending", "client_name": "Hoody CLI", "ticket": "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234" }}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.
curl -i https://api.hoody.com/api/v1/auth/device/authorize \ -G \ --data-urlencode "ticket=abcd1234ticket" \ --data-urlencode "provider=github"import { HoodyClient } from 'hoody-sdk';
const client = new HoodyClient({ baseURL: 'https://api.hoody.com', token: process.env.HOODY_TOKEN });
await client.api.authentication.oauthDeviceAuthorize({ ticket: 'abcd1234ticket', provider: 'github',});Parameters
Section titled “Parameters”| Name | In | Type | Required | Description |
|---|---|---|---|---|
ticket | query | string | Yes | The device_verify_ticket from /device/verify_code. |
provider | query | string | Yes | OAuth provider: github | google. |
A redirect to the OAuth provider is returned.
{ "statusCode": 403, "error": "DEVICE_COOKIE_MISSING", "message": "Device verification cookie is missing or invalid"}{ "statusCode": 410, "error": "DEVICE_TICKET_INVALID", "message": "Device ticket is invalid, expired, or already consumed"}POST /api/v1/auth/device/login
Section titled “POST /api/v1/auth/device/login”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). Credential failures do NOT consume the ticket.
curl -X POST https://api.hoody.com/api/v1/auth/device/login \ -H "Content-Type: application/json" \ -d '{ "ticket": "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234", "username": "alice", "password": "CorrectHorseBatteryStaple!42" }'import { HoodyClient } from 'hoody-sdk';
const client = new HoodyClient({ baseURL: 'https://api.hoody.com', token: process.env.HOODY_TOKEN });
await client.api.authentication.oauthDeviceLogin({ ticket: 'abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234', username: 'alice', password: 'CorrectHorseBatteryStaple!42',});This endpoint takes no path/query/header parameters.
Request body
| Name | Type | Required | Description |
|---|---|---|---|
ticket | string | Yes | device_verify_ticket from /device/verify_code (64 hex chars). |
username | string | Conditional | Username (3-50 chars). |
email | string | Conditional | Email address (max 255 chars). |
password | string | Yes | Account password (8-128 chars). |
{ "statusCode": 200, "data": { "status": "approved" }}{ "statusCode": 401, "error": "INVALID_CREDENTIALS", "message": "Invalid email 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. |
EMAIL_NOT_VERIFIED | Email not verified | The password is correct but the email has not been verified. Response carries data.email for a resend CTA. | Click the verification link or call /auth/resend-verification. |
{ "statusCode": 403, "error": "ACCOUNT_BANNED", "message": "Your account is banned"}| Error Code | Title | Description | Resolution |
|---|---|---|---|
ACCOUNT_BANNED | Account banned | Your account has been banned. The ticket stays live so another account may sign in. | Contact support for information about your account status. |
{ "statusCode": 404, "error": "FEATURE_DISABLED", "message": "Device flow feature-flag is off"}{ "statusCode": 410, "error": "DEVICE_TICKET_INVALID", "message": "Ticket invalid/consumed/superseded, cookie mismatch, or row expired/denied — re-enter the user code"}{ "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. malformed ticket, missing identifier, out-of-bounds field lengths). | Correct the request fields and retry. |
{ "statusCode": 429, "error": "RATE_LIMIT_EXCEEDED", "message": "Rate limit exceeded"}| Error Code | Title | Description | Resolution |
|---|---|---|---|
RATE_LIMIT_EXCEEDED | Rate limit exceeded | You have exceeded the rate limit for this endpoint (failure-only reservation; successes are refunded). | Wait before making additional requests. |
POST /api/v1/auth/device/deny
Section titled “POST /api/v1/auth/device/deny”Page-only helper mirroring the RFC-8628 recommendation that the user can deny. Cookie + ticket gated, no credentials required. Flips the pending row to denied; the terminal poll then reports access_denied. An approved row can never be un-approved.
curl -X POST https://api.hoody.com/api/v1/auth/device/deny \ -H "Content-Type: application/json" \ -d '{ "ticket": "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234" }'import { HoodyClient } from 'hoody-sdk';
const client = new HoodyClient({ baseURL: 'https://api.hoody.com', token: process.env.HOODY_TOKEN });
await client.api.authentication.oauthDeviceDeny({ ticket: 'abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234',});This endpoint takes no path/query/header parameters.
Request body
| Name | Type | Required | Description |
|---|---|---|---|
ticket | string | Yes | device_verify_ticket from /device/verify_code (64 hex chars). |
{ "statusCode": 200, "data": { "status": "denied" }}{ "statusCode": 403, "error": "DEVICE_COOKIE_MISSING", "message": "Device verification cookie is missing or invalid"}{ "statusCode": 404, "error": "FEATURE_DISABLED", "message": "Device flow feature-flag is off"}{ "statusCode": 410, "error": "DEVICE_TICKET_INVALID", "message": "Device ticket is invalid, expired, or already consumed"}{ "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). | Correct the request fields and retry. |
{ "statusCode": 429, "error": "RATE_LIMIT_EXCEEDED", "message": "Rate limit exceeded"}| 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. |
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. Returns 400 with {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. The response includes an identity claim when signing is configured.
curl -X POST https://api.hoody.com/api/v1/auth/device/token \ -H "Content-Type: application/json" \ -d '{ "device_code": "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234" }'import { HoodyClient } from 'hoody-sdk';
const client = new HoodyClient({ baseURL: 'https://api.hoody.com', token: process.env.HOODY_TOKEN });
await client.api.authentication.oauthDeviceToken({ device_code: 'abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234',});This endpoint takes no path/query/header parameters.
Request body
| Name | Type | Required | Description |
|---|---|---|---|
device_code | string | Yes | The device_code from /device/code (64 hex chars). |
code_verifier | string | No | PKCE code_verifier (43-128 chars). Required when code_challenge was supplied to /device/code. |
{ "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": "eyJzdWIiOiI1MDdmMWY3N2JjZjg2Y2Q3OTk0MzkwMTEiLCJ1c2VybmFtZSI6ImFsaWNlIiwidHlwZSI6InVzZXIiLCJpYXQiOjE3NDEyOTAwMDAsImV4cCI6MTc0Mzg4MjAwMCwia2lkIjoidjEifQ", "signature_hex": "abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef12" }, "user": { "id": "507f1f77bcf86cd799439011", "username": "alice", "alias": "Alice", "email": "alice@example.com", "is_admin": false, "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" } }}{ "statusCode": 400, "data": { "error": "authorization_pending" }}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 2FA is enabled, whether setup is verified, and how many backup codes remain.
curl -s https://api.hoody.com/api/v1/users/auth/2fa/status \ -H "Authorization: Bearer $HOODY_JWT"import { HoodyClient } from 'hoody-sdk';
const client = new HoodyClient({ baseURL: 'https://api.hoody.com', token: process.env.HOODY_TOKEN });
await client.api.tfa.getStatus();This endpoint takes no parameters.
{ "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. | Include a valid JWT token in the Authorization header. |
INVALID_TOKEN | Invalid authentication token | The provided token is malformed or invalid. | Obtain a new token by logging in again. |
TOKEN_EXPIRED | Authentication token expired | The provided token has expired. | Refresh the session or log in 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. |
{ "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/setup
Section titled “POST /api/v1/users/auth/2fa/setup”Begin 2FA setup. Requires the current password for verification. Returns the QR code for the authenticator app and backup codes. Important: save backup codes securely — they are shown only once.
curl -X POST https://api.hoody.com/api/v1/users/auth/2fa/setup \ -H "Authorization: Bearer $HOODY_JWT" \ -H "Content-Type: application/json" \ -d '{ "password": "CorrectHorseBatteryStaple!42" }'import { HoodyClient } from 'hoody-sdk';
const client = new HoodyClient({ baseURL: 'https://api.hoody.com', token: process.env.HOODY_TOKEN });
await client.api.tfa.setup({ password: 'CorrectHorseBatteryStaple!42' });This endpoint takes no path/query/header parameters.
Request body
| Name | Type | Required | Description |
|---|---|---|---|
password | string | Yes | Current account password (8-128 chars). |
{ "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": "TWOFACTOR_ALREADY_ENABLED", "message": "2FA is already enabled"}| 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. |
MISSING_REQUIRED_FIELD | Required field missing | password is required. | Include the password field in the request body. |
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. | Include a valid JWT token in the Authorization header. |
INVALID_TOKEN | Invalid authentication token | The provided token is malformed or invalid. | Obtain a new token by logging in again. |
TOKEN_EXPIRED | Authentication token expired | The provided token has expired. | Refresh the session or log in again. |
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 the authenticator app. This confirms the setup is working correctly. All other sessions are revoked and a fresh token/refreshToken pair is returned (sessions_revoked: true); adopt them to keep the current session alive.
curl -X POST https://api.hoody.com/api/v1/users/auth/2fa/verify-setup \ -H "Authorization: Bearer $HOODY_JWT" \ -H "Content-Type: application/json" \ -d '{ "code": "492039" }'import { HoodyClient } from 'hoody-sdk';
const client = new HoodyClient({ baseURL: 'https://api.hoody.com', token: process.env.HOODY_TOKEN });
await client.api.tfa.verifySetup({ code: '492039' });This endpoint takes no path/query/header parameters.
Request body
| Name | Type | Required | Description |
|---|---|---|---|
code | string | Yes | 6-digit code from the authenticator app. |
{ "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. |
MISSING_REQUIRED_FIELD | Required field missing | code is required. | Include the code field in the request body. |
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. | Include a valid JWT token in the Authorization header. |
INVALID_TOKEN | Invalid authentication token | The provided token is malformed or invalid. | Obtain a new token by logging in again. |
TOKEN_EXPIRED | Authentication token expired | The provided token has expired. | Refresh the session or log in 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. |
{ "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. The response includes an identity claim when HOODY_SIGNING_PRIVATE_KEY is configured. In intent mode the response instead carries an opaque auth_intent_token for PKCE exchange. In device-flow mode, the partial JWT carries a device_binding, the device row is approved, and NO tokens are issued to the browser.
curl -X POST https://api.hoody.com/api/v1/users/auth/2fa/verify \ -H "Content-Type: application/json" \ -d '{ "temp_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", "code": "492039" }'import { HoodyClient } from 'hoody-sdk';
const client = new HoodyClient({ baseURL: 'https://api.hoody.com', token: process.env.HOODY_TOKEN });
await client.api.tfa.verify({ temp_token: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...', code: '492039',});This endpoint takes no path/query/header parameters.
Request body
| Name | Type | Required | Description |
|---|---|---|---|
temp_token | string | No | Temporary token from login response (valid 5 min). May also be sent as Authorization: Bearer. |
code | string | Yes | 6-digit OTP code from the authenticator app OR 10-character backup code. |
response_mode | string | No | tokens (default) or intent (PKCE exchange). |
client | string | No | Source channel for analytics. |
{ "statusCode": 200, "message": "Authentication successful", "data": { "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", "identity_claim": { "kid": "v1", "payload_b64": "eyJzdWIiOiI1MDdmMWY3N2JjZjg2Y2Q3OTk0MzkwMTEiLCJ1c2VybmFtZSI6ImFsaWNlIiwidHlwZSI6InVzZXIiLCJpYXQiOjE3NDEyOTAwMDAsImV4cCI6MTc0Mzg4MjAwMCwia2lkIjoidjEifQ", "signature_hex": "abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef12" }, "user": { "id": "507f1f77bcf86cd799439011", "username": "alice", "alias": "Alice" } }}{ "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. |
MISSING_REQUIRED_FIELD | Required field missing | code is required. | Include the code field in the request body. |
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", "data": { "attempts_remaining": 3 }}| 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": 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 a fresh set of backup codes; all existing backup codes are invalidated. Requires the current password and a current OTP code. Important: save the new codes securely.
curl -X POST https://api.hoody.com/api/v1/users/auth/2fa/backup-codes/regenerate \ -H "Authorization: Bearer $HOODY_JWT" \ -H "Content-Type: application/json" \ -d '{ "password": "CorrectHorseBatteryStaple!42", "code": "492039" }'import { HoodyClient } from 'hoody-sdk';
const client = new HoodyClient({ baseURL: 'https://api.hoody.com', token: process.env.HOODY_TOKEN });
await client.api.tfa.regenerateBackupCodes({ password: 'CorrectHorseBatteryStaple!42', code: '492039',});This endpoint takes no path/query/header parameters.
Request body
| Name | Type | Required | Description |
|---|---|---|---|
password | string | Yes | Current account password (8-128 chars). |
code | string | Yes | 6-digit OTP code from the authenticator app. |
{ "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. |
MISSING_REQUIRED_FIELD | Required field missing | password and code are required. | Include both fields in the request body. |
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 | 2FA is not enabled for this account. | Set up 2FA first using /users/auth/2fa/setup. |
{ "statusCode": 401, "error": "INVALID_OTP_CODE", "message": "Invalid or expired 2FA code", "data": { "attempts_remaining": 3 }}| Error Code | Title | Description | Resolution |
|---|---|---|---|
MISSING_TOKEN | Authentication token missing | No authentication token was provided. | Include a valid JWT token in the Authorization header. |
INVALID_TOKEN | Invalid authentication token | The provided token is malformed or invalid. | Obtain a new token by logging in again. |
TOKEN_EXPIRED | Authentication token expired | The provided token has expired. | Refresh the session or log in 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. |
{ "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 the gate is a security downgrade and therefore requires both password and otp_code as a primary-factor re-authentication.
curl -X PUT https://api.hoody.com/api/v1/users/auth/2fa/token-gate \ -H "Authorization: Bearer $HOODY_JWT" \ -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.tfa.setTokenGate({ enabled: true });This endpoint takes no path/query/header parameters.
Request body
| Name | Type | Required | Description |
|---|---|---|---|
enabled | boolean | Yes | true = require OTP for token mutations (default), false = 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. |
{ "statusCode": 200, "message": "Token gate 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 | 2FA is not enabled for this account. | Set up 2FA first using /users/auth/2fa/setup. |
{ "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": 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 the current password and a valid OTP code (or backup code) for security. All other sessions are revoked and a fresh token/refreshToken pair is returned (sessions_revoked: true); adopt them to keep the current session alive.
curl -X DELETE https://api.hoody.com/api/v1/users/auth/2fa \ -H "Authorization: Bearer $HOODY_JWT" \ -H "Content-Type: application/json" \ -d '{ "password": "CorrectHorseBatteryStaple!42", "code": "492039" }'import { HoodyClient } from 'hoody-sdk';
const client = new HoodyClient({ baseURL: 'https://api.hoody.com', token: process.env.HOODY_TOKEN });
await client.api.tfa.disable({ password: 'CorrectHorseBatteryStaple!42', code: '492039',});This endpoint takes no path/query/header parameters.
Request body
| Name | Type | Required | Description |
|---|---|---|---|
password | string | Yes | Current account password (8-128 chars). |
code | string | Yes | 6-digit OTP code from the authenticator app OR backup code. |
{ "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. |
MISSING_REQUIRED_FIELD | Required field missing | password and code are required. | Include both fields in the request body. |
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 | 2FA is not enabled for this account. | Nothing to disable; check status with /users/auth/2fa/status. |
{ "statusCode": 401, "error": "INVALID_OTP_CODE", "message": "Invalid or expired 2FA code", "data": { "attempts_remaining": 3 }}| Error Code | Title | Description | Resolution |
|---|---|---|---|
MISSING_TOKEN | Authentication token missing | No authentication token was provided. | Include a valid JWT token in the Authorization header. |
INVALID_TOKEN | Invalid authentication token | The provided token is malformed or invalid. | Obtain a new token by logging in again. |
TOKEN_EXPIRED | Authentication token expired | The provided token has expired. | Refresh the session or log in 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. |
{ "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. |
Identity claim issuance
Section titled “Identity claim issuance”POST /api/v1/users/auth/identity-claim
Section titled “POST /api/v1/users/auth/identity-claim”Mint 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 with 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 [60, min(server ceiling — default 24h, remaining JWT lifetime)], default 1 hour. When the JWT session has under 60 seconds of life remaining, the server returns 400 REFRESH_REQUIRED so the client refreshes first. Dual rate limits apply (per-IP and per-user).
curl -X POST https://api.hoody.com/api/v1/users/auth/identity-claim \ -H "Authorization: Bearer $HOODY_JWT" \ -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.authentication.api_issueIdentityClaim({ audience: 'myapp.example.com', expires_in: 3600,});This endpoint takes no path/query/header parameters.
Request body
| Name | Type | Required | Description |
|---|---|---|---|
audience | string | Yes | Consumer identifier this claim is bound to (e.g. your app hostname). Printable ASCII, no whitespace or double quotes (1-256 chars). Verifiers reject the claim unless they expect exactly this audience. |
expires_in | integer | No | Requested claim lifetime in seconds. Clamped to [60, min(server ceiling — default 24h, remaining JWT lifetime)]. Default: 1 hour. |
{ "statusCode": 200, "message": "Identity claim issued", "data": { "identity_claim": { "kid": "v1", "payload_b64": "eyJzdWIiOiI1MDdmMWY3N2JjZjg2Y2Q3OTk0MzkwMTEiLCJ1c2VybmFtZSI6ImFsaWNlIiwidHlwZSI6InVzZXIiLCJpYXQiOjE3NDEyOTAwMDAsImV4cCI6MTc0Mzg4MjAwMCwia2lkIjoidjEiLCJhdWQiOiJteWFwcC5leGFtcGxlLmNvbSJ9", "signature_hex": "abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef12" }, "expires_in": 3600, "expires_at": "2025-11-28T21:19:00.000Z", "audience": "myapp.example.com" }}{ "statusCode": 400, "error": "REFRESH_REQUIRED", "message": "Less than 60s of JWT lifetime remaining; refresh your session first"}{ "statusCode": 401, "error": "UNAUTHORIZED", "message": "JWT required"}{ "statusCode": 403, "error": "FORBIDDEN", "message": "Auth tokens, HTTP Basic, and impersonated sessions cannot issue identity claims; use a first-party JWT"}{ "statusCode": 503, "error": "SIGNING_NOT_CONFIGURED", "message": "Response signing is not configured on this API instance"}