Authentication
Section titled “Authentication”The endpoints in this section cover everything you need to sign users in, mint and refresh session tokens, recover accounts, exchange OAuth codes (including the CLI/device flow), and protect sessions with two-factor authentication. They also expose the ED25519 identity claims that let third-party systems verify a Hoody login offline.
For container-side authentication, see Container claims. For proxy-side authentication of upstream calls, see the proxy foundation pages linked from each gating pattern under Identity claims.
Public configuration
Section titled “Public configuration”GET /api/v1/auth/available-regions
Section titled “GET /api/v1/auth/available-regions”Returns the regions where free-tier servers currently exist, with a boolean availability flag for each. Public, no authentication required.
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.authentication.getAvailableRegions();This endpoint takes no parameters.
{ "statusCode": 200, "data": { "regions": [ { "region": "eu-west", "country": "Netherlands", "city": "Amsterdam", "available": true }, { "region": "us-east", "country": "USA", "city": "Ashburn", "available": true }, { "region": "ap-south", "country": "Singapore", "city": "Singapore", "available": false } ] }}GET /api/v1/auth/config
Section titled “GET /api/v1/auth/config”Returns the public sign-in configuration (which identity providers are enabled, and other UI-driving flags). Public, no authentication required.
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.authentication.getOAuthConfig();This endpoint takes no parameters.
{}Public signing key
Section titled “Public signing 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.
keys[] is a rotation array. It carries the next prepublished key plus the previous key retained for verification; always look up the key by kid. On key rotation, Hoody increments kid (for example v1 to v2) and includes both the active and the retired keys in the array for a transition window so existing signatures still verify. New signatures use active_kid.
Third-party verification flow:
- Fetch this endpoint once and cache the result for 24h or longer.
- Locate the key by
kidfrom thekeys[]array. - For response signatures: parse the
X-Hoody-Signatureheader in the formt=<unix_ts>,kid=<key_id>,m=<method>,s=<status>,path=<request_url>,sig=<hex>, then 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(the base64url string itself). - If a
kidin a signature or claim does not match any cached key, re-fetch this endpoint.
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();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": "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 } }}{ "statusCode": 503, "error": "SIGNING_NOT_CONFIGURED", "message": "Response signing is not configured on this API instance"}Identity claims
Section titled “Identity claims”An identity claim is an ED25519-signed credential that proves “Hoody authenticated this user” to systems outside Hoody. It is verified offline against the public key fetched from GET /api/v1/meta/public-key; no live call to Hoody is required.
Hoody returns an identity claim in these flows:
POST /api/v1/users/auth/login(tokens mode) — see LoginPOST /api/v1/users/auth/2fa/verify(tokens mode, after the OTP step) — see Verify 2FAPOST /api/v1/auth/verify-email(tokens mode) — see Verify emailPOST /api/v1/auth/device/token(terminal polling completes) — see Device token- The hosted auth UI PKCE exchange at
POST /api/v1/auth/authorizeandPOST /api/v1/auth/exchange
The claim is omitted when the response mode is intent (hosted auth handoff returns an opaque auth_intent_token instead) and when signing is not configured on the server (HOODY_SIGNING_PRIVATE_KEY unset). If your flow needs a guarantee of issuance, force response_mode: "tokens" and treat a missing identity_claim as a configuration error.
Bundle format
Section titled “Bundle format”| Field | Type | Description |
|---|---|---|
kid | string | Key ID. Look it up in the keys[] rotation array from GET /api/v1/meta/public-key. |
payload_b64 | string | base64url-encoded JSON payload (no padding). The signature is over the UTF-8 bytes of this string. |
signature_hex | string | 128-character lowercase hex (64-byte) ED25519 detached signature. |
Payload format
Section titled “Payload format”| Field | Type | Description |
|---|---|---|
claim_type | string | Always "identity". |
iss | string | Always "hoody-api". |
sub | string | The authenticated user ID (24-character hex). |
username | string | The user’s username at issue time. |
type | string | "user" or "admin". |
iat | number | Issue time (Unix seconds). |
exp | number | Expiry time (Unix seconds). Default ~30 days for login claims; re-issued claims default to 1 hour. |
kid | string | Key ID; must match bundle.kid. |
aud | string | (Re-issued claims only) The audience the claim is bound to. Strict, two-way match required by the verifier. |
Verification (mandatory checks)
Section titled “Verification (mandatory checks)”A verifier MUST run all seven checks below before trusting any field of an identity claim. Reject on first failure.
- Signature over UTF-8 bytes of
payload_b64. Verifysignature_hexagainstBuffer.from(payload_b64, 'utf8')(or equivalent) using the ED25519 public key located bybundle.kidin thekeys[]rotation array. Never re-encode, re-stringify, or re-base64 the payload before verification. claim_type === "identity". Reject anything else; container claims and future claim types are not interchangeable.iss === "hoody-api". Reject any other issuer.exp > nowandexp > iat. The claim must be unexpired and internally consistent.iat <= now + 300. Allow 5 minutes of clock skew; reject anything that claims to be issued in the future beyond that.payload.kid === bundle.kid. The key referenced inside the signed payload must match the key the signature was checked against.- Audience strict, two-way match. A claim is bound to exactly one audience string in
payload.aud. The verifier MUST reject the claim unless the audience the verifier expects is exactly equal topayload.aud. This is mandatory for every verifier, every time, regardless of whether the audience field looks “optional”.
Working verifier (Node/Bun)
Section titled “Working verifier (Node/Bun)”import { createPublicKey, verify, createHash } from 'node:crypto';
// 1. Fetch and cache GET /api/v1/meta/public-key; locate the key by kid:// data.keys.find(k => k.kid === claim.kid)?.public_key_hex
// 2. SPKI prefix for ED25519 public keys (12 bytes):const SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex');
function verifyClaim(claim: { kid: string; payload_b64: string; signature_hex: string }, expectedAudience: string) { const hex = '<' + /* public_key_hex from GET /api/v1/meta/public-key for claim.kid */ '>' as string; const rawKey = Buffer.from(hex, 'hex'); const spki = Buffer.concat([SPKI_PREFIX, rawKey]); const pubKey = createPublicKey({ key: spki, format: 'der', type: 'spki' });
const payload = Buffer.from(claim.payload_b64, 'utf8'); const sig = Buffer.from(claim.signature_hex, 'hex');
if (!verify(null, payload, pubKey, sig)) throw new Error('bad signature'); const body = JSON.parse(claim.payload_b64);
if (body.claim_type !== 'identity') throw new Error('wrong claim_type'); if (body.iss !== 'hoody-api') throw new Error('wrong iss'); const now = Math.floor(Date.now() / 1000); if (!(body.exp > now && body.exp > body.iat)) throw new Error('expired'); if (!(body.iat <= now + 300)) throw new Error('iat in the future'); if (body.kid !== claim.kid) throw new Error('kid mismatch'); if (body.aud !== expectedAudience) throw new Error('audience mismatch');
return body; // trusted}import nacl from 'tweetnacl';
function verifyClaim(claim: { kid: string; payload_b64: string; signature_hex: string }, expectedAudience: string) { const hex = '<' + /* public_key_hex from GET /api/v1/meta/public-key for claim.kid */ '>' as string; const pubKey = Uint8Array.from(Buffer.from(hex, 'hex'));
const payload = new TextEncoder().encode(claim.payload_b64); const sig = Uint8Array.from(Buffer.from(claim.signature_hex, 'hex'));
if (!nacl.sign.detached.verify(payload, sig, pubKey)) throw new Error('bad signature'); const body = JSON.parse(new TextDecoder().decode(payload));
if (body.claim_type !== 'identity') throw new Error('wrong claim_type'); if (body.iss !== 'hoody-api') throw new Error('wrong iss'); const now = Math.floor(Date.now() / 1000); if (!(body.exp > now && body.exp > body.iat)) throw new Error('expired'); if (!(body.iat <= now + 300)) throw new Error('iat in the future'); if (body.kid !== claim.kid) throw new Error('kid mismatch'); if (body.aud !== expectedAudience) throw new Error('audience mismatch');
return body;}Decodable example claim (illustrative):
{ "claim_type": "identity", "iss": "hoody-api", "sub": "67e89abc123def456789abcd", "username": "alice", "type": "user", "iat": 1741290000, "exp": 1743882000, "kid": "v1", "aud": "myapp.example.com"}Gating patterns for proxied apps
Section titled “Gating patterns for proxied apps”There are three ways to forward an identity claim through the proxy so an upstream app can verify it. Pick the one that matches your threat model:
- Native
hoody-identitypermission group. Grant thehoody-identitygroup to the upstream app in proxy permissions; the edge forwards the verified claim to your app unmodified. See proxy permissions —hoody-identity. - App-level via a NON-RESERVED header. Convention:
X-Hoody-Claim: <payload_b64>.<signature_hex>, with the verifier pinning the expectedkid(and the expectedaudfor audience-bound claims). The verifier runs the seven mandatory checks above before trusting any field. - Proxy hook. A
proxy_hookcan inspect the request and inject the claim header on the upstream call after applying your own policy. See proxy hooks —identity-claim-auth-gate; the recipe likewise uses the non-reservedx-hoody-claimheader.
For container-side use (a program running inside a Hoody container reading the claim), see Container claims — the format and verification are the same, but the delivery mechanism differs.
OAuth device and launch flows
Section titled “OAuth device and launch flows”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 and the __Host-device_verify cookie atomically and redirects to the provider with a server-injected device binding and attempt nonce. Sets Referrer-Policy: no-referrer.
curl -i "https://api.hoody.com/api/v1/auth/device/authorize?ticket=0000000000000000000000000000000000000000000000000000000000000000&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: '0000000000000000000000000000000000000000000000000000000000000000', provider: 'github'});Parameters
Section titled “Parameters”| Name | In | Type | Required | Description |
|---|---|---|---|---|
ticket | query | string | Yes | device_verify_ticket from /device/verify_code |
provider | query | string | Yes | One of "github", "google" |
{}{ "statusCode": 403, "error": "Forbidden", "message": "Device authorize is unavailable"}{ "statusCode": 410, "error": "Gone", "message": "Device ticket has been consumed or expired"}GET /api/v1/auth/github
Section titled “GET /api/v1/auth/github”Redirects the browser to GitHub for OAuth authentication. Browser-only endpoint.
curl -i "https://api.hoody.com/api/v1/auth/github?redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback&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({ 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. Unknown values recorded as unknown. |
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). Required — all OAuth flows must use PKCE post-migration. |
invite_code | query | string | No | Optional invite code (“coupon”). Hashed at redirect time; only the hash travels in OAuth state. |
{}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?state=<signed_state>&code=<oauth_code>"import { HoodyClient } from 'hoody-sdk';
const client = new HoodyClient({ baseURL: 'https://api.hoody.com', token: process.env.HOODY_TOKEN });
await client.api.authentication.githubOAuthCallback({ state: '<signed_state>' });Parameters
Section titled “Parameters”| Name | In | Type | Required | Description |
|---|---|---|---|---|
code | query | string | No | OAuth authorization code from GitHub. |
state | query | string | Yes | Signed OAuth state echoed from the redirect. |
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 description. |
error_uri | query | string | No | Provider-supplied URI. |
{}GET /api/v1/auth/google
Section titled “GET /api/v1/auth/google”Redirects the browser to Google for OAuth authentication. Browser-only endpoint.
curl -i "https://api.hoody.com/api/v1/auth/google?redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback&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({ 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. Unknown values 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). |
invite_code | query | string | No | Optional invite code (“coupon”). Hashed at redirect time. |
{}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?state=<signed_state>&code=<oauth_code>"import { HoodyClient } from 'hoody-sdk';
const client = new HoodyClient({ baseURL: 'https://api.hoody.com', token: process.env.HOODY_TOKEN });
await client.api.authentication.googleOAuthCallback({ state: '<signed_state>' });Parameters
Section titled “Parameters”| Name | In | Type | Required | Description |
|---|---|---|---|---|
code | query | string | No | OAuth authorization code from Google. |
state | query | string | Yes | Signed OAuth state echoed from the redirect. |
error | query | string | No | Provider-side failure code (e.g. access_denied). |
error_description | query | string | No | Provider-supplied description. |
error_uri | query | string | No | Provider-supplied URI. |
{}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.
curl -i "https://api.hoody.com/api/v1/auth/launch/start?ticket=<launch_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: '<launch_ticket>' });Parameters
Section titled “Parameters”| Name | In | Type | Required | Description |
|---|---|---|---|---|
ticket | query | string | Yes | One-shot ticket from /launch/initiate response |
{}{ "statusCode": 410, "error": "Gone", "message": "Launch ticket has been consumed or expired"}POST /api/v1/auth/authorize
Section titled “POST /api/v1/auth/authorize”Registers a PKCE authorization request (code challenge and redirect URI) to begin 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 parameters.
Request Body
| Name | Type | Required | Description |
|---|---|---|---|
code_challenge | string | Yes | PKCE code challenge (base64url SHA-256 of code_verifier). |
redirect_uri | string | Yes | Frontend URL to redirect to after authorization (must start with https://). |
{}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 not standards-compliant: no client_id/grant_type, lifecycle errors are nested under data, and the poll interval is a fixed 5s after slow_down.
curl -X POST https://api.hoody.com/api/v1/auth/device/code \ -H 'Content-Type: application/json' \ -d '{ "client_name": "Hoody 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.authentication.oauthDeviceCode({ client_name: 'Hoody CLI', code_challenge: 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM'});This endpoint takes no parameters.
Request Body
| Name | Type | Required | Description |
|---|---|---|---|
client_name | string | No | Shown on the verification page as “X is requesting access”. |
client | string | No | Source channel for analytics: web, ssh, webssh, cli, sdk, agent. Unknown values recorded as unknown. |
code_challenge | string | No | PKCE on the device flow itself; if present, the poll REQUIRES the verifier. |
{ "statusCode": 200, "data": { "device_code": "0000000000000000000000000000000000000000000000000000000000000000", "user_code": "ABCD-1234", "verification_uri": "https://app.example.com/device", "verification_uri_complete": "https://app.example.com/device?user_code=ABCD-1234", "interval": 5, "expires_in": 600 }}POST /api/v1/auth/device/deny
Section titled “POST /api/v1/auth/device/deny”Page-only helper. Mirrors the RFC-8628 recommendation that the user can deny. Cookie + ticket gated, no credentials required — possession of the live ticket and cookie is the refusing authority. 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": "0000000000000000000000000000000000000000000000000000000000000000" }'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: '0000000000000000000000000000000000000000000000000000000000000000'});This endpoint takes no parameters.
Request Body
| Name | Type | Required | Description |
|---|---|---|---|
ticket | string | Yes | device_verify_ticket from /device/verify_code. |
{ "statusCode": 200, "data": { "status": "denied" }}{ "statusCode": 403, "error": "Forbidden", "message": "Device cookie missing or mismatch"}{ "statusCode": 404, "error": "Not Found", "message": "Device flow feature-flag is off"}{ "statusCode": 410, "error": "Gone", "message": "Device ticket has been consumed or expired"}{ "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": "Too Many Requests", "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/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 and __Host-device_verify cookie gate. Never returns session tokens: no-2FA returns {status:"approved"} (tokens mint only at /device/token); 2FA returns {requires_2fa, temp_token} (device-bound partial, no code_challenge). Credential failures do NOT consume the ticket. Feature-flag off returns 404; schema-invalid body returns 422.
curl -X POST https://api.hoody.com/api/v1/auth/device/login \ -H 'Content-Type: application/json' \ -d '{ "ticket": "0000000000000000000000000000000000000000000000000000000000000000", "username": "alice", "password": "SecurePassword123!" }'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: '0000000000000000000000000000000000000000000000000000000000000000', username: 'alice', password: 'SecurePassword123!'});This endpoint takes no parameters.
Request Body
| Name | 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. |
{ "statusCode": 200, "data": { "status": "approved" }}{ "statusCode": 401, "error": "Unauthorized", "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 or use the password reset feature |
EMAIL_NOT_VERIFIED | Email not verified | Returned when the password is correct but the email has not been verified | Complete email verification by clicking the link, calling /auth/resend-verification, or completing a password reset |
{ "statusCode": 403, "error": "Forbidden", "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-flag is off"}{ "statusCode": 410, "error": "Gone", "message": "Ticket invalid/consumed/superseded, cookie mismatch, or row expired/denied"}{ "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": "Too Many Requests", "message": "Rate limit exceeded"}| Error Code | Title | Description | Resolution |
|---|---|---|---|
RATE_LIMIT_EXCEEDED | Rate limit exceeded | IP-wide failed-attempt ceiling (failure-only reservation; successes are refunded) | 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), and 200 with the token set on approval (single-use; also requires the approving user’s session generation to still be current — a password reset or logout-all after approval yields expired_token). Returns 429 on the outer flood guard. Public, no auth. Lifecycle errors are nested under data, unlike RFC 8628 §3.5.
curl -X POST https://api.hoody.com/api/v1/auth/device/token \ -H 'Content-Type: application/json' \ -d '{ "device_code": "0000000000000000000000000000000000000000000000000000000000000000", "code_verifier": "kJpQ6yIBl5R7yHnL4Yd2fMz3aW9eTv0cN1sXrUaVpBgGhTqOiAkDjFlVnCx" }'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: '0000000000000000000000000000000000000000000000000000000000000000', code_verifier: 'kJpQ6yIBl5R7yHnL4Yd2fMz3aW9eTv0cN1sXrUaVpBgGhTqOiAkDjFlVnCx'});This endpoint takes no parameters.
Request Body
| Name | Type | Required | Description |
|---|---|---|---|
device_code | string | Yes | device_code from /device/code. |
code_verifier | string | No | PKCE code_verifier (required when code_challenge was set in /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": "eyJzdWIiOiI2N2U4OWFiYzEyM2RlZjQ1Njc4OWFiY2QiLCJ1c2VybmFtZSI6ImFsaWNlIiwidHlwZSI6InVzZXIiLCJpYXQiOjE3NDEyOTAwMDAsImV4cCI6MTc0Mzg4MjAwMCwia2lkIjoidjEifQ", "signature_hex": "abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef12" }, "user": { "id": "67e89abc123def456789abcd", "username": "alice", "alias": "Alice", "email": "alice@example.com", "is_admin": false, "email_verified": true, "signup_method": "github", "avatar_url": null, "created_at": "2024-12-01T10:00:00.000Z", "updated_at": "2025-01-15T10:30:00.000Z" }, "server": { "id": "67e89abc123def456789abcd", "name": "node-us-01", "country": "USA", "region": "us-east", "city": "Ashburn", "datacenter": "iad1", "is_ready": true }, "project": { "id": "67e89abc123def456789abcd", "alias": "default" }, "container": { "id": "890abcdef12345678901cdef", "name": "main", "status": "running" } }}{ "statusCode": 400, "data": { "error": "authorization_pending" }}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.
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 parameters.
Request Body
| Name | Type | Required | Description |
|---|---|---|---|
user_code | string | Yes | XXXX-XXXX user code (dashes optional). |
{ "statusCode": 200, "data": { "client_name": "Hoody CLI", "status": "pending" }}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.
curl -X POST https://api.hoody.com/api/v1/auth/exchange \ -H 'Content-Type: application/json' \ -d '{ "code": "0000000000000000000000000000000000000000000000000000000000000000", "code_verifier": "kJpQ6yIBl5R7yHnL4Yd2fMz3aW9eTv0cN1sXrUaVpBgGhTqOiAkDjFlVnCx", "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: '0000000000000000000000000000000000000000000000000000000000000000', code_verifier: 'kJpQ6yIBl5R7yHnL4Yd2fMz3aW9eTv0cN1sXrUaVpBgGhTqOiAkDjFlVnCx', redirect_uri: 'https://app.example.com/callback'});This endpoint takes no parameters.
Request Body
| Name | Type | Required | Description |
|---|---|---|---|
code | string | Yes | Authorization code returned by the OAuth callback. |
code_verifier | string | Yes | PKCE code_verifier (43-128 chars). |
redirect_uri | string | Yes | Frontend URL to redirect to (must match the original authorize call). |
{}POST /api/v1/auth/intent/cancel
Section titled “POST /api/v1/auth/intent/cancel”Cancels a pending OAuth AuthIntent or 2FA temp_token. Requires Authorization: Bearer <intent_or_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 <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.authentication.oauthCancelIntent();This endpoint takes no parameters and no request body.
{}POST /api/v1/auth/launch/initiate
Section titled “POST /api/v1/auth/launch/initiate”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 and opener_origin plumbed through.
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": "67e89ab0-1234-4def-9abc-def4567890ab" }'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: '67e89ab0-1234-4def-9abc-def4567890ab'});This endpoint takes no parameters.
Request Body
| Name | Type | Required | Description |
|---|---|---|---|
provider | string | Yes | One of "github", "google". |
client | string | No | 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. |
{ "statusCode": 200, "data": { "launch_url": "https://api.hoody.com/api/v1/auth/launch/start?ticket=<launch_ticket>" }}Password and account recovery
Section titled “Password and account recovery”POST /api/v1/auth/forgot-password
Section titled “POST /api/v1/auth/forgot-password”Sends 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 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 password reset link has been sent."}POST /api/v1/auth/resend-verification
Section titled “POST /api/v1/auth/resend-verification”Resends 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 parameters.
Request Body
| Name | Type | Required | Description |
|---|---|---|---|
email | string | Yes | Email address to resend verification to. |
{ "statusCode": 200, "message": "If the email is registered and unverified, a new verification link has been sent."}POST /api/v1/auth/reset-password
Section titled “POST /api/v1/auth/reset-password”Sets 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": "0000000000000000000000000000000000000000000000000000000000000000", "password": "NewSecurePassword123!" }'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: '0000000000000000000000000000000000000000000000000000000000000000', password: 'NewSecurePassword123!'});This endpoint takes no parameters.
Request Body
| Name | Type | Required | Description |
|---|---|---|---|
token | string | Yes | Password reset token from the email link. |
password | string | Yes | New password (min 12 chars). |
{ "statusCode": 200, "message": "Password reset successful"}{ "statusCode": 400, "message": "Reset token is invalid or has expired"}POST /api/v1/auth/signup
Section titled “POST /api/v1/auth/signup”Creates 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": "SecurePassword123!" }'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: 'SecurePassword123!'});This endpoint takes no parameters.
Request Body
| Name | Type | Required | Description |
|---|---|---|---|
email | string | Yes | Email address for the new account. |
password | string | Yes | Password (min 12 chars, must include uppercase, lowercase, number, and special char). |
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”). Memorized (hash-only) and applied automatically after email verification. |
client | string | No | Source channel for analytics. |
{ "statusCode": 200, "message": "Account created. Check your email to verify.", "data": { "email": "alice@example.com" }}{ "statusCode": 400, "message": "Password must be at least 12 characters and include uppercase, lowercase, number, and special character"}{ "statusCode": 403, "message": "Signups are currently disabled"}POST /api/v1/auth/verify-email
Section titled “POST /api/v1/auth/verify-email”Verifies the email address using the token from the verification email. Default response returns full login credentials. When response_mode=intent and 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 and temp_token instead. See Identity claims for the audience-bound re-issue flow.
curl -X POST https://api.hoody.com/api/v1/auth/verify-email \ -H 'Content-Type: application/json' \ -d '{ "token": "0000000000000000000000000000000000000000000000000000000000000000" }'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: '0000000000000000000000000000000000000000000000000000000000000000'});This endpoint takes no parameters.
Request Body
| Name | Type | Required | Description |
|---|---|---|---|
client | string | No | Source channel for analytics. |
token | string | Yes | Verification token from the email link. |
response_mode | string | No | "intent" returns an auth_intent_token; "tokens" (default) returns access/refresh tokens. |
code_challenge | string | No | PKCE code_challenge (base64url SHA-256 of code_verifier). Required when response_mode=intent. |
{ "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": "eyJzdWIiOiI2N2U4OWFiYzEyM2RlZjQ1Njc4OWFiY2QiLCJ1c2VybmFtZSI6ImFsaWNlIiwidHlwZSI6InVzZXIiLCJpYXQiOjE3NDEyOTAwMDAsImV4cCI6MTc0Mzg4MjAwMCwia2lkIjoidjEifQ", "signature_hex": "abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef12" }, "user": { "id": "67e89abc123def456789abcd", "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" }, "server": { "id": "67e89abc123def456789abcd", "name": "node-us-01", "country": "USA", "region": "us-east", "city": "Ashburn", "datacenter": "iad1", "is_ready": true }, "project": { "id": "67e89abc123def456789abcd", "alias": "default" }, "container": { "id": "890abcdef12345678901cdef", "name": "main" } }}{ "statusCode": 400, "message": "Verification token is invalid or has expired"}Account session
Section titled “Account session”POST /api/v1/users/auth/login
Section titled “POST /api/v1/users/auth/login”Authenticates with username and password and returns a JWT access token (1 day) and a refresh token (7 days). Use the access token in the Authorization header for subsequent requests: Authorization: Bearer <token>. See Identity claims for the optional audience-bound re-issue flow.
curl -X POST https://api.hoody.com/api/v1/users/auth/login \ -H 'Content-Type: application/json' \ -d '{ "username": "alice", "password": "SecurePassword123!" }'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: 'SecurePassword123!'});This endpoint takes no parameters.
Request Body
| Name | Type | Required | Description |
|---|---|---|---|
username | string | No | Username (alternative to email). |
email | string | No | Email address (alternative to username). |
password | string | Yes | Account password. |
response_mode | string | No | "intent" returns an auth_intent_token; "tokens" (default) returns access/refresh tokens. |
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": "eyJzdWIiOiI2N2U4OWFiYzEyM2RlZjQ1Njc4OWFiY2QiLCJ1c2VybmFtZSI6ImFsaWNlIiwidHlwZSI6InVzZXIiLCJpYXQiOjE3NDEyOTAwMDAsImV4cCI6MTc0Mzg4MjAwMCwia2lkIjoidjEifQ", "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": "67e89abc123def456789abcd", "username": "alice", "alias": "Alice", "email": "alice@example.com", "is_admin": false, "is_banned": false, "email_verified": true, "avatar_url": null, "signup_method": "email", "metadata": {}, "created_at": "2024-12-01T10:00:00.000Z", "updated_at": "2025-01-15T10:30:00.000Z" } }}{ "statusCode": 400, "error": "Bad Request", "message": "Validation failed"}| Error Code | Title | Description | Resolution |
|---|---|---|---|
VALIDATION_ERROR | Invalid input parameters | One or more request parameters failed validation | Check the error message and correct your input |
MISSING_REQUIRED_FIELD | Required field missing | One or more required fields are missing | Include all required fields |
{ "statusCode": 401, "error": "Unauthorized", "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 or use the password reset feature |
EMAIL_NOT_VERIFIED | Email not verified | The password is correct but the email has not been verified yet | Complete email verification by clicking the link, calling /auth/resend-verification, or completing a password reset |
POST /api/v1/users/auth/logout
Section titled “POST /api/v1/users/auth/logout”Logs out the current user and creates an audit log entry. Note: in a stateless JWT setup the client should discard the token. This endpoint works even for banned users.
curl -X POST https://api.hoody.com/api/v1/users/auth/logout \ -H 'Authorization: Bearer <token>'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 no request body.
{ "statusCode": 200, "message": "Logout successful"}{ "statusCode": 401, "error": "Unauthorized", "message": "Authentication token required"}| Error Code | Title | Description | Resolution |
|---|---|---|---|
MISSING_TOKEN | Authentication token missing | No token in the request | Provide Authorization: Bearer <token> |
INVALID_TOKEN | Invalid authentication token | Token is malformed or invalid | Log in again |
TOKEN_EXPIRED | Authentication token expired | The token has expired | Refresh the token or log in again |
POST /api/v1/users/auth/refresh
Section titled “POST /api/v1/users/auth/refresh”Exchanges a valid refresh token for a new access token and new refresh token. Send the refresh token in the body.
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 parameters.
Request Body
| Name | Type | Required | Description |
|---|---|---|---|
refreshToken | string | Yes | Valid refresh token from a previous login/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": "Unauthorized", "message": "Token has expired"}| Error Code | Title | Description | Resolution |
|---|---|---|---|
MISSING_TOKEN | Authentication token missing | No token in the request | Provide Authorization: Bearer <token> |
INVALID_TOKEN | Invalid authentication token | Token is malformed or invalid | Log in again |
TOKEN_EXPIRED | Authentication token expired | The token has expired | Refresh the token or log in again |
GET /api/v1/users/auth/me
Section titled “GET /api/v1/users/auth/me”Retrieves the profile of the currently authenticated user. Works with JWT, auth token, or 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 https://api.hoody.com/api/v1/users/auth/me \ -H 'Authorization: Bearer <token>'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": "67e89abc123def456789abcd", "username": "alice", "alias": "Alice", "email": "alice@example.com", "is_admin": false, "is_banned": false, "email_verified": true, "metadata": {}, "created_at": "2024-12-01T10:00:00.000Z", "updated_at": "2025-01-15T10:30:00.000Z" }}{ "statusCode": 401, "error": "Unauthorized", "message": "Authentication token required"}| Error Code | Title | Description | Resolution |
|---|---|---|---|
MISSING_TOKEN | Authentication token missing | No token in the request | Provide Authorization: Bearer <token> |
INVALID_TOKEN | Invalid authentication token | Token is malformed or invalid | Log in again |
TOKEN_EXPIRED | Authentication token expired | The token has expired | Refresh the token or log in again |
GET /api/v1/users/me
Section titled “GET /api/v1/users/me”Alias of GET /api/v1/users/auth/me. Returns the same response.
curl https://api.hoody.com/api/v1/users/me \ -H 'Authorization: Bearer <token>'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": "67e89abc123def456789abcd", "username": "alice", "alias": "Alice", "email": "alice@example.com", "is_admin": false, "is_banned": false, "email_verified": true, "metadata": {}, "created_at": "2024-12-01T10:00:00.000Z", "updated_at": "2025-01-15T10:30:00.000Z" }}{ "statusCode": 401, "error": "Unauthorized", "message": "Authentication token required"}| Error Code | Title | Description | Resolution |
|---|---|---|---|
MISSING_TOKEN | Authentication token missing | No token in the request | Provide Authorization: Bearer <token> |
INVALID_TOKEN | Invalid authentication token | Token is malformed or invalid | Log in again |
TOKEN_EXPIRED | Authentication token expired | The token has expired | Refresh the token or log in again |
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 a re-login. First-party JWT sessions only — auth tokens, HTTP Basic, and impersonated sessions are rejected. 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)]; default 1 hour.
The endpoint enforces two rate limits in parallel: a per-account limit (default 30/minute) and a global limit. Under 60 seconds of JWT remaining lifetime the call returns 400 REFRESH_REQUIRED — refresh the access token first.
curl -X POST https://api.hoody.com/api/v1/users/auth/identity-claim \ -H 'Content-Type: application/json' \ -H 'Authorization: Bearer <jwt_token>' \ -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 parameters.
Request Body
| Name | 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 (1h). |
{ "statusCode": 200, "message": "Identity claim issued", "data": { "identity_claim": { "kid": "v1", "payload_b64": "eyJzdWIiOiI2N2U4OWFiYzEyM2RlZjQ1Njc4OWFiY2QiLCJ1c2VybmFtZSI6ImFsaWNlIiwidHlwZSI6InVzZXIiLCJpYXQiOjE3NDEyOTAwMDAsImV4cCI6MTc0MTI5MzYwMCwia2lkIjoidjEiLCJhdWQiOiJteWFwcC5leGFtcGxlLmNvbSJ9", "signature_hex": "abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef12" }, "expires_in": 3600, "expires_at": "2025-03-08T10:00:00.000Z", "audience": "myapp.example.com" }}{ "statusCode": 400, "error": "REFRESH_REQUIRED", "message": "JWT lifetime is too short; refresh the access token and retry"}{ "statusCode": 401, "error": "UNAUTHORIZED", "message": "Authentication required"}{ "statusCode": 403, "error": "FORBIDDEN", "message": "Auth tokens, Basic auth, and impersonated sessions cannot mint identity claims"}{ "statusCode": 503, "error": "SIGNING_NOT_CONFIGURED", "message": "Response signing is not configured on this API instance"}Two-factor authentication
Section titled “Two-factor authentication”GET /api/v1/users/auth/2fa/status
Section titled “GET /api/v1/users/auth/2fa/status”Returns the current 2FA status for the authenticated user, including whether it is enabled and how many backup codes remain.
curl https://api.hoody.com/api/v1/users/auth/2fa/status \ -H 'Authorization: Bearer <token>'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": "Unauthorized", "message": "Authentication token required"}| Error Code | Title | Description | Resolution |
|---|---|---|---|
MISSING_TOKEN | Authentication token missing | No token in the request | Provide Authorization: Bearer <token> |
INVALID_TOKEN | Invalid authentication token | Token is malformed or invalid | Log in again |
TOKEN_EXPIRED | Authentication token expired | The token has expired | Refresh the token or log in again |
INVALID_OTP_CODE | Invalid OTP code | The provided 2FA code is incorrect or has expired | Generate a new code from the authenticator app |
{ "statusCode": 429, "error": "Too Many Requests", "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 attempts; account temporarily locked | Wait for the lockout period (15 minutes) to expire |
POST /api/v1/users/auth/2fa/setup
Section titled “POST /api/v1/users/auth/2fa/setup”Begins 2FA setup. Requires the current password for verification. Returns a QR code for an authenticator app and backup codes. The backup codes are shown only once — save them securely. On success, all sessions are revoked and a fresh token/refreshToken pair is returned so the calling client can adopt the new session without re-authenticating.
curl -X POST https://api.hoody.com/api/v1/users/auth/2fa/setup \ -H 'Content-Type: application/json' \ -H 'Authorization: Bearer <token>' \ -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.tfa.setup({ password: 'SecurePassword123!' });This endpoint takes no parameters.
Request Body
| Name | Type | Required | Description |
|---|---|---|---|
password | string | Yes | Current account password for verification. |
{ "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": "Bad Request", "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 and correct your input |
MISSING_REQUIRED_FIELD | Required field missing | password is missing | Include password |
INCORRECT_PASSWORD | Incorrect password | The password does not match the account | Verify the password and retry |
TWOFACTOR_ALREADY_ENABLED | 2FA already enabled | 2FA is already enabled on the account | Disable 2FA first if you want to set it up again |
{ "statusCode": 401, "error": "Unauthorized", "message": "Authentication token required"}| Error Code | Title | Description | Resolution |
|---|---|---|---|
MISSING_TOKEN | Authentication token missing | No token in the request | Provide Authorization: Bearer <token> |
INVALID_TOKEN | Invalid authentication token | Token is malformed or invalid | Log in again |
TOKEN_EXPIRED | Authentication token expired | The token has expired | Refresh the token or log in again |
POST /api/v1/users/auth/2fa/verify-setup
Section titled “POST /api/v1/users/auth/2fa/verify-setup”Verifies and completes 2FA setup by providing the first code from your authenticator app. On success, all sessions are revoked and a fresh token/refreshToken pair is returned; sessions_revoked is always true.
curl -X POST https://api.hoody.com/api/v1/users/auth/2fa/verify-setup \ -H 'Content-Type: application/json' \ -H 'Authorization: Bearer <token>' \ -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.tfa.verifySetup({ code: '123456' });This endpoint takes no 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": "Bad Request", "message": "2FA setup incomplete - verification required"}| Error Code | Title | Description | Resolution |
|---|---|---|---|
VALIDATION_ERROR | Invalid input parameters | One or more request parameters failed validation | Check the error message and correct your input |
MISSING_REQUIRED_FIELD | Required field missing | code is missing | Include code |
INVALID_OTP_CODE | Invalid OTP code | The code is incorrect or expired | Generate a new code from the authenticator app |
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": "Unauthorized", "message": "Authentication token required"}| Error Code | Title | Description | Resolution |
|---|---|---|---|
MISSING_TOKEN | Authentication token missing | No token in the request | Provide Authorization: Bearer <token> |
INVALID_TOKEN | Invalid authentication token | Token is malformed or invalid | Log in again |
TOKEN_EXPIRED | Authentication token expired | The token has expired | Refresh the token or log in again |
INVALID_OTP_CODE | Invalid OTP code | The code is incorrect or expired | Generate a new code from the authenticator app |
{ "statusCode": 429, "error": "Too Many Requests", "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 attempts; account temporarily locked | Wait for the lockout period (15 minutes) to expire |
POST /api/v1/users/auth/2fa/verify
Section titled “POST /api/v1/users/auth/2fa/verify”Completes 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. See Identity claims for the optional audience-bound re-issue flow.
curl -X POST https://api.hoody.com/api/v1/users/auth/2fa/verify \ -H 'Content-Type: application/json' \ -d '{ "temp_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", "code": "123456" }'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: '123456'});This endpoint takes no parameters.
Request Body
| Name | Type | Required | Description |
|---|---|---|---|
temp_token | string | No | Temporary token from login response (5-minute TTL). Can also be sent as Authorization: Bearer. |
code | string | Yes | 6-digit OTP code OR 10-character backup code |
response_mode | string | No | "intent" returns an auth_intent_token; "tokens" (default) returns access/refresh tokens |
client | string | No | Source channel for analytics |
{ "statusCode": 200, "message": "Authentication 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": "eyJzdWIiOiI2N2U4OWFiYzEyM2RlZjQ1Njc4OWFiY2QiLCJ1c2VybmFtZSI6ImFsaWNlIiwidHlwZSI6InVzZXIiLCJpYXQiOjE3NDEyOTAwMDAsImV4cCI6MTc0Mzg4MjAwMCwia2lkIjoidjEifQ", "signature_hex": "abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef1234abcdef12" } }}{ "statusCode": 400, "error": "Bad Request", "message": "Temporary token expired or invalid"}| Error Code | Title | Description | Resolution |
|---|---|---|---|
VALIDATION_ERROR | Invalid input parameters | One or more request parameters failed validation | Check the error message and correct your input |
MISSING_REQUIRED_FIELD | Required field missing | code is missing | Include code in the body |
INVALID_OTP_CODE | Invalid OTP code | The code is incorrect or expired | Generate a new code from the authenticator app |
INVALID_BACKUP_CODE | Invalid backup code | The backup code is incorrect or already used | Verify the backup code is correct and unused |
INVALID_TEMP_TOKEN | Invalid temporary token | The temp_token expired or is invalid | Log in again to get a new temp_token |
DEVICE_BINDING_GONE | Device authorization no longer pending | The device-flow temp_token references an authorization that expired, was denied, or was superseded | Re-enter the user code from the terminal and start over |
{ "statusCode": 401, "error": "Unauthorized", "message": "Invalid or expired 2FA code"}| Error Code | Title | Description | Resolution |
|---|---|---|---|
INVALID_OTP_CODE | Invalid OTP code | The code is incorrect or expired | Generate a new code from the authenticator app |
INVALID_BACKUP_CODE | Invalid backup code | The backup code is incorrect or already used | Verify the backup code is correct and unused |
{ "statusCode": 429, "error": "Too Many Requests", "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 attempts; account temporarily locked | Wait for the lockout period (15 minutes) to expire |
POST /api/v1/users/auth/2fa/backup-codes/regenerate
Section titled “POST /api/v1/users/auth/2fa/backup-codes/regenerate”Generates a fresh set of backup codes (invalidates all existing ones). Requires the current password and a current OTP code.
curl -X POST https://api.hoody.com/api/v1/users/auth/2fa/backup-codes/regenerate \ -H 'Content-Type: application/json' \ -H 'Authorization: Bearer <token>' \ -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.tfa.regenerateBackupCodes({ password: 'SecurePassword123!', code: '123456'});This endpoint takes no parameters.
Request Body
| Name | Type | Required | Description |
|---|---|---|---|
password | string | Yes | Current account password |
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": "Bad Request", "message": "Validation failed"}| Error Code | Title | Description | Resolution |
|---|---|---|---|
VALIDATION_ERROR | Invalid input parameters | One or more request parameters failed validation | Check the error message and correct your input |
MISSING_REQUIRED_FIELD | Required field missing | password and/or code is missing | Include both password and code |
INCORRECT_PASSWORD | Incorrect password | The password does not match the account | Verify the password and retry |
INVALID_OTP_CODE | Invalid OTP code | The code is incorrect or expired | Generate a new code from the authenticator app |
TWOFACTOR_NOT_ENABLED | 2FA not enabled | 2FA is not enabled on the account | Set up 2FA first |
{ "statusCode": 401, "error": "Unauthorized", "message": "Authentication token required"}| Error Code | Title | Description | Resolution |
|---|---|---|---|
MISSING_TOKEN | Authentication token missing | No token in the request | Provide Authorization: Bearer <token> |
INVALID_TOKEN | Invalid authentication token | Token is malformed or invalid | Log in again |
TOKEN_EXPIRED | Authentication token expired | The token has expired | Refresh the token or log in again |
INVALID_OTP_CODE | Invalid OTP code | The code is incorrect or expired | Generate a new code from the authenticator app |
{ "statusCode": 429, "error": "Too Many Requests", "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 attempts; account temporarily locked | Wait for the lockout period (15 minutes) to expire |
PUT /api/v1/users/auth/2fa/token-gate
Section titled “PUT /api/v1/users/auth/2fa/token-gate”Enables or disables the OTP requirement for token mutation operations. Setting enabled=false (a security downgrade) requires both password and otp_code for primary-factor re-authentication. Note: the HTTP verb is PUT; the Patch suffix in the operationId is a naming artifact, not the method.
curl -X PUT https://api.hoody.com/api/v1/users/auth/2fa/token-gate \ -H 'Content-Type: application/json' \ -H 'Authorization: Bearer <token>' \ -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 parameters.
Request Body
| Name | Type | Required | Description |
|---|---|---|---|
enabled | boolean | Yes | true = require OTP for token mutations (default); false = skip the OTP gate |
password | string | No | Required when enabled=false (security downgrade requires primary-factor reauth) |
otp_code | string | No | TOTP code or backup code. Required when enabled=false. |
{ "statusCode": 200, "message": "Token gate preference updated", "data": { "require_for_tokens": true }}{ "statusCode": 400, "error": "Bad Request", "message": "2FA verification required for this operation"}| Error Code | Title | Description | Resolution |
|---|---|---|---|
OTP_REQUIRED | 2FA verification required | The account has 2FA enabled and this operation requires an OTP | Provide otp_code with a valid TOTP or backup code |
TWOFACTOR_NOT_ENABLED | 2FA not enabled | 2FA is not enabled on the account | Set up 2FA first |
{ "statusCode": 401, "error": "Unauthorized", "message": "Invalid or expired 2FA code"}| Error Code | Title | Description | Resolution |
|---|---|---|---|
INVALID_OTP_CODE | Invalid OTP code | The code is incorrect or expired | Generate a new code from the authenticator app |
INCORRECT_PASSWORD | Incorrect password | The password does not match the account | Verify the password and retry |
{ "statusCode": 429, "error": "Too Many Requests", "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 attempts; account temporarily locked | Wait for the lockout period (15 minutes) to expire |
DELETE /api/v1/users/auth/2fa
Section titled “DELETE /api/v1/users/auth/2fa”Disables 2FA for the account. Requires both the current password and a valid OTP code (or backup code). On success, all sessions are revoked and a fresh token/refreshToken pair is returned so the calling client can adopt the new session without re-authenticating. sessions_revoked is always true.
curl -X DELETE https://api.hoody.com/api/v1/users/auth/2fa \ -H 'Content-Type: application/json' \ -H 'Authorization: Bearer <token>' \ -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.tfa.disable({ password: 'SecurePassword123!', code: '123456'});This endpoint takes no parameters.
Request Body
| Name | Type | Required | Description |
|---|---|---|---|
password | string | Yes | Current account password |
code | string | Yes | 6-digit OTP code 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": "Bad Request", "message": "Validation failed"}| Error Code | Title | Description | Resolution |
|---|---|---|---|
VALIDATION_ERROR | Invalid input parameters | One or more request parameters failed validation | Check the error message and correct your input |
MISSING_REQUIRED_FIELD | Required field missing | password and/or code is missing | Include both password and code |
INCORRECT_PASSWORD | Incorrect password | The password does not match the account | Verify the password and retry |
INVALID_OTP_CODE | Invalid OTP code | The code is incorrect or expired | Generate a new code from the authenticator app |
INVALID_BACKUP_CODE | Invalid backup code | The backup code is incorrect or already used | Verify the backup code is correct and unused |
TWOFACTOR_NOT_ENABLED | 2FA not enabled | 2FA is not enabled on the account | Set up 2FA first |
{ "statusCode": 401, "error": "Unauthorized", "message": "Authentication token required"}| Error Code | Title | Description | Resolution |
|---|---|---|---|
MISSING_TOKEN | Authentication token missing | No token in the request | Provide Authorization: Bearer <token> |
INVALID_TOKEN | Invalid authentication token | Token is malformed or invalid | Log in again |
TOKEN_EXPIRED | Authentication token expired | The token has expired | Refresh the token or log in again |
INVALID_OTP_CODE | Invalid OTP code | The code is incorrect or expired | Generate a new code from the authenticator app |
INVALID_BACKUP_CODE | Invalid backup code | The backup code is incorrect or already used | Verify the backup code is correct and unused |
{ "statusCode": 429, "error": "Too Many Requests", "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 attempts; account temporarily locked | Wait for the lockout period (15 minutes) to expire |