Skip to content
Hoody.com

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.

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.

Terminal window
curl -s https://api.hoody.com/api/v1/meta/public-key

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
}
}
}

Returns regions where free-tier servers exist, with a boolean availability flag for each. Public, no authentication required.

Terminal window
curl -s https://api.hoody.com/api/v1/auth/available-regions

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 }
]
}
}

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).

Terminal window
curl -s https://api.hoody.com/api/v1/auth/config

This endpoint takes no parameters.

The default response shape is returned.


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 (default tokens mode — see Login)
  • POST /api/v1/users/auth/2fa/verify (default tokens mode — see Verify 2FA Code During Login)
  • POST /api/v1/auth/verify-email (when response_mode=tokens and 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

FieldTypeDescription
kidstringKey identifier; look up the matching key in GET /api/v1/meta/public-key keys[]
payload_b64stringbase64url-encoded UTF-8 JSON containing the payload below
signature_hexstringED25519 detached signature over the UTF-8 bytes of payload_b64 (128 hex chars = 64 bytes)

Payload (payload_b64 decoded)

FieldTypeDescription
claim_typestringAlways "identity"
issstringAlways "hoody-api"
substringUser ID (24-character hex)
usernamestringThe authenticated username at the time of issue
typestring"user" or "admin"
iatintegerIssued-at (Unix seconds)
expintegerExpiry (Unix seconds); default ~30 days
kidstringThe signing key id used; must equal the bundle kid
audstringOptional 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:

  1. Look the kid up in GET /api/v1/meta/public-key keys[] and obtain the 32-byte ED25519 public key. If the kid is not in keys[], re-fetch the endpoint (key rotation) and try again; reject if still missing.
  2. Verify the signature: nacl.sign.detached.verify(UTF8(payload_b64), hexToBytes(signature_hex), publicKey) returns true. The signed bytes are the UTF-8 bytes of the payload_b64 string itself, not the decoded JSON.
  3. payload.claim_type === "identity".
  4. payload.iss === "hoody-api".
  5. payload.exp > now and payload.exp > payload.iat.
  6. payload.iat <= now + 300 (clock skew tolerance).
  7. payload.kid === bundle.kid and audience semantics are strict both ways: if payload.aud is present, the verifier MUST be that audience AND the verifier’s own identifier MUST be the audience; if payload.aud is 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),
};

Gating patterns for proxied applications

There are three ways to gate a proxied application on Hoody authentication, in order of preference:

  1. Native hoody-identity permission group. Add the hoody-identity.authentication permission to the app’s proxy permissions; the edge enforces it before the request reaches the app. See Proxy permissions: hoody-identity.
  2. 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 is X-Hoody-Claim: &lt;payload_b64&gt;.&lt;signature_hex&gt; carrying a pinned kid. The app looks up the kid in its cached /api/v1/meta/public-key response and runs the seven checks above.
  3. Proxy hook. For per-route policy that isn’t a single permission group, attach an identity_claim_auth_gate hook that performs the same verification on the claim header. See Proxy hooks: identity_claim_auth_gate — that recipe likewise uses the non-reserved x-hoody-claim header.

For container-scoped claims (programs running inside a container that need to authenticate against a third party), see Container claims on the Containers API.


Create a new account with email and password. A verification email is sent; the account is not active until the email is verified.

Terminal window
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"
}'

This endpoint takes no path/query/header parameters.

Request body

NameTypeRequiredDescription
emailstringYesEmail address for the new account (max 255 chars).
passwordstringYesPassword (min 12, max 128 chars; must include uppercase, lowercase, number, and special char).
regionstringNoOptional preferred server region (e.g. eu-west). Auto-assigned by GeoIP if omitted.
invite_codestringNoOptional invite code from the signup link. Memorized (hash-only) and applied after email verification.
clientstringNoSource channel for analytics: web | ssh | webssh | cli | sdk | agent.
{
"statusCode": 200,
"message": "Verification email sent",
"data": { "email": "alice@example.com" }
}

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.

Terminal window
curl -X POST https://api.hoody.com/api/v1/auth/verify-email \
-H "Content-Type: application/json" \
-d '{
"token": "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234",
"response_mode": "tokens"
}'

This endpoint takes no path/query/header parameters.

Request body

NameTypeRequiredDescription
tokenstringYesVerification token from the email link (64 chars).
response_modestringNotokens (default — returns access/refresh tokens) or intent (returns an opaque auth_intent_token for PKCE exchange).
code_challengestringNoPKCE code_challenge (base64url SHA-256 of code_verifier). Required when response_mode=intent.
clientstringNoSource 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"
}
}
}

Resend the email verification link. Always returns success to prevent email enumeration.

Terminal window
curl -X POST https://api.hoody.com/api/v1/auth/resend-verification \
-H "Content-Type: application/json" \
-d '{ "email": "alice@example.com" }'

This endpoint takes no path/query/header parameters.

Request body

NameTypeRequiredDescription
emailstringYesEmail address to resend verification to (max 255 chars).
{
"statusCode": 200,
"message": "If an account exists for that email, a verification link has been sent"
}

Request a password reset email. Always returns success to prevent email enumeration.

Terminal window
curl -X POST https://api.hoody.com/api/v1/auth/forgot-password \
-H "Content-Type: application/json" \
-d '{ "email": "alice@example.com" }'

This endpoint takes no path/query/header parameters.

Request body

NameTypeRequiredDescription
emailstringYesEmail address associated with the account.
{
"statusCode": 200,
"message": "If an account exists for that email, a reset link has been sent"
}

Set a new password using the reset token from the password reset email.

Terminal window
curl -X POST https://api.hoody.com/api/v1/auth/reset-password \
-H "Content-Type: application/json" \
-d '{
"token": "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234",
"password": "NewSecurePass!42"
}'

This endpoint takes no path/query/header parameters.

Request body

NameTypeRequiredDescription
tokenstringYesPassword reset token from the email link (64 chars).
passwordstringYesNew password (min 12, max 128 chars).
{
"statusCode": 200,
"message": "Password reset successful"
}

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 &lt;token&gt;. 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.

Terminal window
curl -X POST https://api.hoody.com/api/v1/users/auth/login \
-H "Content-Type: application/json" \
-d '{
"username": "alice",
"password": "CorrectHorseBatteryStaple!42"
}'

This endpoint takes no path/query/header parameters.

Request body

NameTypeRequiredDescription
usernamestringConditionalUsername (3-50 chars; alphanumeric, underscores, hyphens).
emailstringConditionalEmail address (max 255 chars; alternative to username).
passwordstringYesAccount password (8-128 chars; must include uppercase, lowercase, and number).
response_modestringNotokens (default) or intent (PKCE exchange).
clientstringNoSource channel for analytics.
code_challengestringNoPKCE 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"
}
}
}

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 &lt;refreshToken&gt;.

Terminal window
curl -X POST https://api.hoody.com/api/v1/users/auth/refresh \
-H "Content-Type: application/json" \
-d '{
"refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}'

This endpoint takes no path/query/header parameters.

Request body

NameTypeRequiredDescription
refreshTokenstringYesValid 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
}
}

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).

Terminal window
curl -X POST https://api.hoody.com/api/v1/users/auth/logout \
-H "Authorization: Bearer $HOODY_JWT"

This endpoint takes no parameters and accepts no request body.

{
"statusCode": 200,
"message": "Logout successful"
}

Cancel a pending OAuth AuthIntent or 2FA temp_token. Idempotent. Used by the handoff page when the user dismisses the confirmation.

Terminal window
curl -X POST https://api.hoody.com/api/v1/auth/intent/cancel \
-H "Authorization: Bearer $HOODY_INTENT_TOKEN"

This endpoint takes no parameters and accepts no request body.

The default no-content response is returned.


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.

Terminal window
curl -s https://api.hoody.com/api/v1/users/auth/me \
-H "Authorization: Bearer $HOODY_JWT"

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
}
}

Alias of GET /api/v1/users/auth/me. Same response shape and error model.

Terminal window
curl -s https://api.hoody.com/api/v1/users/me \
-H "Authorization: Bearer $HOODY_JWT"

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"
}
}

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.

Terminal window
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"
}'

This endpoint takes no path/query/header parameters.

Request body

NameTypeRequiredDescription
code_challengestringYesPKCE code_challenge (base64url SHA-256 of code_verifier; 43 chars).
redirect_uristringYesFrontend URL to redirect to after OAuth completes (must be https://).

The default response shape is returned.


Complete the PKCE authorization-code flow by exchanging an authorization code and its code verifier for authentication tokens.

Terminal window
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"
}'

This endpoint takes no path/query/header parameters.

Request body

NameTypeRequiredDescription
codestringYesAuthorization code (64 hex chars).
code_verifierstringYesPKCE code_verifier (43-128 chars).
redirect_uristringYesThe same redirect URI used in the authorization request.

The default response shape is returned.


Redirects the browser to GitHub for OAuth authentication. Browser-only endpoint. PKCE is required post-migration; code_challenge is a required query parameter.

Terminal window
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"
NameInTypeRequiredDescription
clientquerystringNoSource channel for analytics: web | ssh | webssh | cli | sdk | agent. Folded into the signed OAuth state.
intentquerystringNoOAuth intent: login (default). star_check is accepted but ignored (retired).
redirect_uriquerystringYesFrontend URL to redirect to after OAuth completes (must be on an allowed domain).
code_challengequerystringYesPKCE code_challenge (base64url SHA-256 of code_verifier).
invite_codequerystringNoOptional invite code captured from the signup link.

A redirect to the GitHub OAuth provider is returned.


Handles the GitHub OAuth callback. Browser-only endpoint.

Terminal window
curl -i https://api.hoody.com/api/v1/auth/github/callback \
-G \
--data-urlencode "code=abcd1234github_code" \
--data-urlencode "state=eyJhbGciOi..."
NameInTypeRequiredDescription
codequerystringNoOAuth code returned by GitHub.
statequerystringYesSigned OAuth state JWT returned by /auth/github.
errorquerystringNoProvider-side failure code (e.g. access_denied). Present instead of code when the user declines.
error_descriptionquerystringNoProvider-side error description.
error_uriquerystringNoProvider-side error URI.

A redirect to the frontend after OAuth completes is returned.


Redirects the browser to Google for OAuth authentication. Browser-only endpoint. PKCE is required post-migration; code_challenge is a required query parameter.

Terminal window
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"
NameInTypeRequiredDescription
clientquerystringNoSource channel for analytics.
redirect_uriquerystringYesFrontend URL to redirect to after OAuth completes.
code_challengequerystringYesPKCE code_challenge (base64url SHA-256 of code_verifier).
invite_codequerystringNoOptional invite code captured from the signup link.

A redirect to the Google OAuth provider is returned.


Handles the Google OAuth callback. Browser-only endpoint.

Terminal window
curl -i https://api.hoody.com/api/v1/auth/google/callback \
-G \
--data-urlencode "code=abcd1234google_code" \
--data-urlencode "state=eyJhbGciOi..."
NameInTypeRequiredDescription
codequerystringNoOAuth code returned by Google.
statequerystringYesSigned OAuth state JWT returned by /auth/google.
errorquerystringNoProvider-side failure code (e.g. access_denied).
error_descriptionquerystringNoProvider-side error description.
error_uriquerystringNoProvider-side error URI.

A redirect to the frontend after OAuth completes is returned.


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.

Terminal window
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"
}'

This endpoint takes no path/query/header parameters.

Request body

NameTypeRequiredDescription
providerstringYesOAuth provider: github | google.
clientstringNoSource channel for analytics.
code_challengestringYesPKCE code_challenge (43 chars).
state_idstringYesPer-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=..."
}
}

Consumes the launch ticket atomically and runs the existing OAuth redirect flow. Sets Referrer-Policy: no-referrer.

Terminal window
curl -i https://api.hoody.com/api/v1/auth/launch/start \
-G \
--data-urlencode "ticket=abcd1234launch_ticket"
NameInTypeRequiredDescription
ticketquerystringYesOne-shot ticket from /launch/initiate response.

A redirect into the PKCE-protected OAuth flow is returned.


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.

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.

Terminal window
curl -X POST https://api.hoody.com/api/v1/auth/device/code \
-H "Content-Type: application/json" \
-d '{
"client_name": "Hoody CLI",
"client": "cli"
}'

This endpoint takes no path/query/header parameters.

Request body

NameTypeRequiredDescription
client_namestringNoShown on the verification page as “X is requesting access” (max 64 chars).
clientstringNoSource channel for analytics.
code_challengestringNoOptional 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
}
}

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.

Terminal window
curl -X POST https://api.hoody.com/api/v1/auth/device/verify_code \
-H "Content-Type: application/json" \
-d '{ "user_code": "ABCD-1234" }'

This endpoint takes no path/query/header parameters.

Request body

NameTypeRequiredDescription
user_codestringYesThe XXXX-XXXX user code (dashes optional; max 16 chars).
{
"statusCode": 200,
"data": {
"status": "pending",
"client_name": "Hoody CLI",
"ticket": "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234"
}
}

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.

Terminal window
curl -i https://api.hoody.com/api/v1/auth/device/authorize \
-G \
--data-urlencode "ticket=abcd1234ticket" \
--data-urlencode "provider=github"
NameInTypeRequiredDescription
ticketquerystringYesThe device_verify_ticket from /device/verify_code.
providerquerystringYesOAuth provider: github | google.

A redirect to the OAuth provider is returned.


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.

Terminal window
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"
}'

This endpoint takes no path/query/header parameters.

Request body

NameTypeRequiredDescription
ticketstringYesdevice_verify_ticket from /device/verify_code (64 hex chars).
usernamestringConditionalUsername (3-50 chars).
emailstringConditionalEmail address (max 255 chars).
passwordstringYesAccount password (8-128 chars).
{
"statusCode": 200,
"data": { "status": "approved" }
}

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.

Terminal window
curl -X POST https://api.hoody.com/api/v1/auth/device/deny \
-H "Content-Type: application/json" \
-d '{
"ticket": "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234"
}'

This endpoint takes no path/query/header parameters.

Request body

NameTypeRequiredDescription
ticketstringYesdevice_verify_ticket from /device/verify_code (64 hex chars).
{
"statusCode": 200,
"data": { "status": "denied" }
}

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.

Terminal window
curl -X POST https://api.hoody.com/api/v1/auth/device/token \
-H "Content-Type: application/json" \
-d '{
"device_code": "abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234abcd1234"
}'

This endpoint takes no path/query/header parameters.

Request body

NameTypeRequiredDescription
device_codestringYesThe device_code from /device/code (64 hex chars).
code_verifierstringNoPKCE 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"
}
}
}

Check the current 2FA status for the authenticated user, including whether 2FA is enabled, whether setup is verified, and how many backup codes remain.

Terminal window
curl -s https://api.hoody.com/api/v1/users/auth/2fa/status \
-H "Authorization: Bearer $HOODY_JWT"

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
}
}

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.

Terminal window
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" }'

This endpoint takes no path/query/header parameters.

Request body

NameTypeRequiredDescription
passwordstringYesCurrent 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"
]
}
}

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.

Terminal window
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" }'

This endpoint takes no path/query/header parameters.

Request body

NameTypeRequiredDescription
codestringYes6-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
}
}

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.

Terminal window
curl -X POST https://api.hoody.com/api/v1/users/auth/2fa/verify \
-H "Content-Type: application/json" \
-d '{
"temp_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"code": "492039"
}'

This endpoint takes no path/query/header parameters.

Request body

NameTypeRequiredDescription
temp_tokenstringNoTemporary token from login response (valid 5 min). May also be sent as Authorization: Bearer.
codestringYes6-digit OTP code from the authenticator app OR 10-character backup code.
response_modestringNotokens (default) or intent (PKCE exchange).
clientstringNoSource 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"
}
}
}

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.

Terminal window
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"
}'

This endpoint takes no path/query/header parameters.

Request body

NameTypeRequiredDescription
passwordstringYesCurrent account password (8-128 chars).
codestringYes6-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"
]
}
}

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.

Terminal window
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 }'

This endpoint takes no path/query/header parameters.

Request body

NameTypeRequiredDescription
enabledbooleanYestrue = require OTP for token mutations (default), false = skip OTP gate.
passwordstringNoRequired when setting enabled=false (security downgrade requires primary-factor reauth).
otp_codestringNoTOTP code or backup code. Required when setting enabled=false.
{
"statusCode": 200,
"message": "Token gate updated",
"data": { "require_for_tokens": true }
}

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.

Terminal window
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"
}'

This endpoint takes no path/query/header parameters.

Request body

NameTypeRequiredDescription
passwordstringYesCurrent account password (8-128 chars).
codestringYes6-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
}
}

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).

Terminal window
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
}'

This endpoint takes no path/query/header parameters.

Request body

NameTypeRequiredDescription
audiencestringYesConsumer 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_inintegerNoRequested 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"
}
}