Authentication
Section titled “Authentication”Nearly all Hoody API operations require authentication. Only a few endpoints are public, such as GET /api/v1/notifications/public. There are two authentication systems, one for interactive user sessions and one for automation.
For what the API does (platform management), see the Hoody API overview.
API endpoints summary
Section titled “API endpoints summary”This Foundation page explains how authentication works and how to use it safely. For the complete endpoint reference:
User authentication (JWT tokens):
- POST /api/v1/users/auth/login - Login with username/password
- POST /api/v1/users/auth/refresh - Refresh access token
- GET /api/v1/users/auth/me - Get current user profile
- POST /api/v1/users/auth/2fa/verify - Complete login when the account has 2FA enabled
- POST /api/v1/users/auth/logout - Revoke all previously issued session JWTs for the token owner
- GET /api/v1/users/me/security-history - List your sign-in and security-event history
Automation (Auth Tokens):
- POST /api/v1/auth/tokens - Create long-lived token
- GET /api/v1/auth/tokens - List all tokens
- GET /api/v1/auth/tokens/{id} - Get token details
- PATCH /api/v1/auth/tokens/{id} - Update token configuration
- DELETE /api/v1/auth/tokens/{id} - Revoke token
Two authentication systems
Section titled “Two authentication systems”JWT tokens (user sessions)
Use for: browser sessions, interactive work
POST /api/v1/users/auth/loginReturns:
token(access, 1 day)refreshToken(7 days)
Characteristics:
- Short-lived
- Refreshable without re-login
- Tied to one user account
- Not suited to automation
Auth Tokens (automation)
Use for: scripts, AI agents, CI/CD, integrations
POST /api/v1/auth/tokensReturns:
hdy_...token (long-lived)
Characteristics:
- Long-lived, with a configurable expiry
- IP whitelist support
- Revocable at any time
- Per-token permissions
- Suited to automation
Authentication workflow
Section titled “Authentication workflow”Interactive use in the browser or CLI
Section titled “Interactive use in the browser or CLI”Step 1: Log in
# Login with email and password (or use --username instead of --email)hoody login --email you@example.com --password your_passwordimport { HoodyClient } from 'hoody-sdk';
const client = new HoodyClient({ baseURL: 'https://api.hoody.com' });
// Login with credentials: pass `email` OR `username` (not both required)const auth = await client.api.auth.login({ email: 'you@example.com', password: 'your_password'});// A 2FA account gets a challenge instead of tokens (see below)if (!auth.data || !('token' in auth.data)) { throw new Error('This account asks for a second factor: finish the 2FA step below');}const { token, refreshToken } = auth.data;console.log(token); // JWT access tokenconsole.log(refreshToken); // Refresh token# Provide EITHER email OR username (only password is required)curl -X POST "https://api.hoody.com/api/v1/users/auth/login" \ -H "Content-Type: application/json" \ -d '{"email": "you@example.com", "password": "your_password"}'# Alternative: -d '{"username": "your_username", "password": "your_password"}'One request, one link
cURL runs inside your container and can wrap any HTTP request into a single GET URL. The call stops being something you need a client for and becomes something you can paste into a browser, send in a chat, bookmark, schedule with cron, or drop into a no-code tool.
Nothing is installed on the machine that opens it. The link does carry whatever credentials the call needs, so treat it as you would treat those credentials.
Slashes, colons and braces pass through as they are. The one character you must
encode is an & inside a value, which happens when the wrapped URL
carries its own query string. Left raw it ends the value early, and the rest is
read as cURL's own parameters, so you get a 200 on a request you did
not make.
How the wrapping works Chaining calls into one link Turning a link into a shortcut
Log in with an email and password to get an access token and refresh token. Swap email for username if that is the identifier you use.
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://api.hoody.com/api/v1/users/auth/login&method=POST&json={"email":"you@example.com","password":"your_password"}&response=transparent The link carries a credential and executes with it, so it is as sensitive as the credential itself — and it passes through the cURL service's request log on the way, not just the target's. Share it only where you would share the secret, and prefer a delegated token with minimal permissions and an expiry: see API tokens.
Response:
{ "statusCode": 200, "message": "Login successful", "data": { "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", "user": { "id": "63f8b0e5c9a1b2d3e4f5a6b7", "username": "your_username", "alias": "Your Display Name", "is_banned": false, "created_at": "2025-10-21T10:00:00.000Z", "updated_at": "2025-10-21T10:00:00.000Z" } }}That response assumes the account has no second factor. When 2FA is enabled and verified, login still answers 200, but the body is a challenge rather than a session, with no token and no refreshToken:
{ "statusCode": 200, "message": "2FA verification required", "data": { "requires_2fa": true, "temp_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...", "method": "totp" }}Check data.requires_2fa before reading data.token, or a client written against the first shape will read undefined and look like a broken login. The temp_token lasts five minutes and is single-use. Exchange it at POST /api/v1/users/auth/2fa/verify, passing a six-digit code from the authenticator app or a ten-character backup code, and that call returns the same token and refreshToken pair shown above:
curl -X POST "https://api.hoody.com/api/v1/users/auth/2fa/verify" \ -H "Content-Type: application/json" \ -d '{"temp_token": "TEMP_TOKEN", "code": "123456"}'The authentication reference documents the rest of the 2FA endpoints, including setup and backup-code regeneration.
Step 2: Use the access token
# CLI stores the token automatically after loginhoody projects list// Pass token to client constructorconst client = new HoodyClient({ baseURL: 'https://api.hoody.com', token // from Step 1});const projects = await client.api.projects.list();# Include token in Authorization headercurl "https://api.hoody.com/api/v1/projects/" \ -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."One request, one link
cURL runs inside your container and can wrap any HTTP request into a single GET URL. The call stops being something you need a client for and becomes something you can paste into a browser, send in a chat, bookmark, schedule with cron, or drop into a no-code tool.
Nothing is installed on the machine that opens it. The link does carry whatever credentials the call needs, so treat it as you would treat those credentials.
Slashes, colons and braces pass through as they are. The one character you must
encode is an & inside a value, which happens when the wrapped URL
carries its own query string. Left raw it ends the value early, and the rest is
read as cURL's own parameters, so you get a 200 on a request you did
not make.
How the wrapping works Chaining calls into one link Turning a link into a shortcut
List your projects using the access token from login. Paste in your own token before using the link.
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://api.hoody.com/api/v1/projects/&method=GET&bearer_token=ACCESS_TOKEN&response=transparent The link carries a credential and executes with it, so it is as sensitive as the credential itself — and it passes through the cURL service's request log on the way, not just the target's. Share it only where you would share the secret, and prefer a delegated token with minimal permissions and an expiry: see API tokens.
Step 3: Refresh when the token expires
# Refresh explicitly using the refresh token saved by loginhoody auth refresh# If the refresh token has expired, log in again:hoody login --username your_username --password your_password// The refresh token is the bearer credential for this call, so build// a client whose token is the refresh tokenconst refreshClient = new HoodyClient({ baseURL: 'https://api.hoody.com', token: refreshToken // from Step 1});const refreshed = await refreshClient.api.auth.refresh({ refreshToken});console.log(refreshed.data.token); // New access token# The refresh token goes in BOTH the Authorization header and the bodycurl -X POST "https://api.hoody.com/api/v1/users/auth/refresh" \ -H "Content-Type: application/json" \ -H "Authorization: Bearer $REFRESH_TOKEN" \ -d '{"refreshToken": "'"$REFRESH_TOKEN"'"}'One request, one link
cURL runs inside your container and can wrap any HTTP request into a single GET URL. The call stops being something you need a client for and becomes something you can paste into a browser, send in a chat, bookmark, schedule with cron, or drop into a no-code tool.
Nothing is installed on the machine that opens it. The link does carry whatever credentials the call needs, so treat it as you would treat those credentials.
Slashes, colons and braces pass through as they are. The one character you must
encode is an & inside a value, which happens when the wrapped URL
carries its own query string. Left raw it ends the value early, and the rest is
read as cURL's own parameters, so you get a 200 on a request you did
not make.
How the wrapping works Chaining calls into one link Turning a link into a shortcut
Exchange the refresh token for a new access token and refresh token. Use the same value for both the placeholder in the body and the bearer token, or the server rejects the request.
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://api.hoody.com/api/v1/users/auth/refresh&method=POST&bearer_token=REFRESH_TOKEN&json={"refreshToken":"REFRESH_TOKEN"}&response=transparent The link carries a credential and executes with it, so it is as sensitive as the credential itself — and it passes through the cURL service's request log on the way, not just the target's. Share it only where you would share the secret, and prefer a delegated token with minimal permissions and an expiry: see API tokens.
The refresh token must be sent both in the Authorization: Bearer header and in the refreshToken body field. The server verifies the header token and rejects the request with 401 Invalid refresh token if the two do not match byte for byte.
Returns: a new access token and a new refresh token, both rotated.
Step 4: Log out
curl -X POST "https://api.hoody.com/api/v1/users/auth/logout" \ -H "Authorization: Bearer $ACCESS_TOKEN"Logout is more than a record. Called with a JWT, it revokes every session JWT issued to that user before the call: the access token you just used, its refresh token, and any session open in another browser or on another device. Use it when you want to end all sessions at once, not only the one in front of you.
The revocation applies only to logouts made with a JWT. The same call made with an Auth Token revokes nothing. To revoke an Auth Token, delete it at DELETE /api/v1/auth/tokens/{id} or disable it.
Automation and AI agents (recommended)
Section titled “Automation and AI agents (recommended)”Step 1: Create an Auth Token (one-time setup)
# Login firsthoody login --username your_username --password your_password
# Create a long-lived automation token with IP whitelisthoody auth tokens create \ --alias "Production Automation Token" \ --ip-whitelist "203.0.113.10,203.0.113.20" \ --expires-at "2027-04-12T00:00:00Z" \ --permissions-projects-read \ --permissions-containers-read \ -o jsonimport { HoodyClient } from 'hoody-sdk';
// Login first to get JWTconst client = new HoodyClient({ baseURL: 'https://api.hoody.com' });const auth = await client.api.auth.login({ username: 'your_username', password: 'your_password'});
if (!auth.data || !('token' in auth.data)) { throw new Error('This account asks for a second factor: finish the 2FA step first');}
// Create auth token using JWTconst jwtClient = new HoodyClient({ baseURL: 'https://api.hoody.com', token: auth.data.token });const token = await jwtClient.api.auth.tokens.create({ alias: 'Production Automation Token', ip_whitelist: ['203.0.113.10', '203.0.113.20'], permissions: { projects: { read: true }, containers: { read: true } }, expires_at: '2027-04-12T00:00:00Z'});console.log(token.data.token); // hdy_... save this value now# Login to get JWTcurl -X POST "https://api.hoody.com/api/v1/users/auth/login" \ -H "Content-Type: application/json" \ -d '{"username": "your_username", "password": "your_password"}'
# Create a long-lived automation tokencurl -X POST "https://api.hoody.com/api/v1/auth/tokens" \ -H "Authorization: Bearer $JWT" \ -H "Content-Type: application/json" \ -d '{ "alias": "Production Automation Token", "ip_whitelist": ["203.0.113.10", "203.0.113.20"], "permissions": { "projects": { "read": true }, "containers": { "read": true } }, "expires_at": "2027-04-12T00:00:00Z" }'One request, one link
cURL runs inside your container and can wrap any HTTP request into a single GET URL. The call stops being something you need a client for and becomes something you can paste into a browser, send in a chat, bookmark, schedule with cron, or drop into a no-code tool.
Nothing is installed on the machine that opens it. The link does carry whatever credentials the call needs, so treat it as you would treat those credentials.
Slashes, colons and braces pass through as they are. The one character you must
encode is an & inside a value, which happens when the wrapped URL
carries its own query string. Left raw it ends the value early, and the rest is
read as cURL's own parameters, so you get a 200 on a request you did
not make.
How the wrapping works Chaining calls into one link Turning a link into a shortcut
Log in to get a JWT, then use it to create the long-lived automation token. Run the two links in order, carrying the JWT from the first into the second.
# Login
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://api.hoody.com/api/v1/users/auth/login&method=POST&json={"username":"your_username","password":"your_password"}&response=transparent
# Create token
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://api.hoody.com/api/v1/auth/tokens&method=POST&bearer_token=JWT&json={"alias":"Production%20Automation%20Token","ip_whitelist":["203.0.113.10","203.0.113.20"],"expires_at":"2027-04-12T00:00:00Z","permissions":{"projects":{"read":true},"containers":{"read":true}}}&response=transparent The link carries a credential and executes with it, so it is as sensitive as the credential itself — and it passes through the cURL service's request log on the way, not just the target's. Share it only where you would share the secret, and prefer a delegated token with minimal permissions and an expiry: see API tokens.
Response:
{ "statusCode": 201, "message": "Auth token created successfully", "data": { "token": "hdy_abc123XyZ456...", "id": "63f8b0e5c9a1b2d3e4f5a6b7", "alias": "Production Automation Token", "prefix": "hdy_", "ip_whitelist": ["203.0.113.10", "203.0.113.20"], "permissions": { "projects": { "read": true }, "containers": { "read": true } }, "vault_access": false, "event_access": true, "expires_at": "2027-04-12T00:00:00.000Z", "is_enabled": true, "last_used_at": null, "last_used_ip": null, "created_at": "2025-11-09T15:00:00.000Z", "updated_at": "2025-11-09T15:00:00.000Z" }}This token holds containers.read but not containers.features.env_values, so it can list a container’s environment variable names but receives each value as null. Add "features": { "env_values": true } under containers if the automation needs the values.
Step 2: Use the Auth Token (until it expires or you revoke it)
# Store token and use with CLIexport HOODY_TOKEN="hdy_abc123XyZ456..."
# All subsequent commands use this tokenhoody projects listhoody containers list// Use auth token in SDK clientconst client = new HoodyClient({ baseURL: 'https://api.hoody.com', token: process.env.HOODY_TOKEN // hdy_abc123XyZ456...});
// All API calls are authenticatedconst projects = await client.api.projects.list();# Store in environment variable (never hardcode)export HOODY_TOKEN="hdy_abc123XyZ456..."
# Use in all API requestscurl "https://api.hoody.com/api/v1/projects/" \ -H "Authorization: Bearer $HOODY_TOKEN"One request, one link
cURL runs inside your container and can wrap any HTTP request into a single GET URL. The call stops being something you need a client for and becomes something you can paste into a browser, send in a chat, bookmark, schedule with cron, or drop into a no-code tool.
Nothing is installed on the machine that opens it. The link does carry whatever credentials the call needs, so treat it as you would treat those credentials.
Slashes, colons and braces pass through as they are. The one character you must
encode is an & inside a value, which happens when the wrapped URL
carries its own query string. Left raw it ends the value early, and the rest is
read as cURL's own parameters, so you get a 200 on a request you did
not make.
How the wrapping works Chaining calls into one link Turning a link into a shortcut
List your projects using the Auth Token instead of a JWT. Paste in your own hdy_... token before using the link.
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://api.hoody.com/api/v1/projects/&method=GET&bearer_token=HOODY_TOKEN&response=transparent The link carries a credential and executes with it, so it is as sensitive as the credential itself — and it passes through the cURL service's request log on the way, not just the target's. Share it only where you would share the secret, and prefer a delegated token with minimal permissions and an expiry: see API tokens.
An Auth Token gives you:
- Credentials that live outside your code
- IP whitelist enforcement, restricting use to specific addresses
- Expiration control: an ISO 8601 date, or never
- Instant revocation, by disabling or deleting the token
Auth Token management
Section titled “Auth Token management”Create tokens with restrictions
Section titled “Create tokens with restrictions”alias accepts letters, digits, spaces, _ and - only (1 to 254 characters). Slashes and parentheses are rejected with 400.
IP whitelisting:
For production, replace full_access with an explicit permission tree containing the operations the automation actually needs.
expires_at accepts several formats:
{ "expires_at": "2026-12-31T23:59:59Z"}{ "expires_at": 1830297599}{ "expires_at": "tomorrow"}Accepts today (expires at midnight tonight) or tomorrow (expires in 24 hours).
{ "alias": "Production Services", "permission_template": "full_access"}Omit expires_at entirely on create and the token never expires. "expires_at": null is only valid on PATCH /api/v1/auth/tokens/{id}, where it clears an existing expiry.
List and audit tokens
Section titled “List and audit tokens”# List all your auth tokenshoody auth tokens listconst tokens = await client.api.auth.tokens.list();tokens.data.forEach(t => { console.log(t.alias, t.is_enabled, t.expires_at);});curl "https://api.hoody.com/api/v1/auth/tokens" \ -H "Authorization: Bearer $HOODY_TOKEN"One request, one link
cURL runs inside your container and can wrap any HTTP request into a single GET URL. The call stops being something you need a client for and becomes something you can paste into a browser, send in a chat, bookmark, schedule with cron, or drop into a no-code tool.
Nothing is installed on the machine that opens it. The link does carry whatever credentials the call needs, so treat it as you would treat those credentials.
Slashes, colons and braces pass through as they are. The one character you must
encode is an & inside a value, which happens when the wrapped URL
carries its own query string. Left raw it ends the value early, and the rest is
read as cURL's own parameters, so you get a 200 on a request you did
not make.
How the wrapping works Chaining calls into one link Turning a link into a shortcut
List every Auth Token on your account, including its expiry, enabled state and IP whitelist.
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://api.hoody.com/api/v1/auth/tokens&method=GET&bearer_token=HOODY_TOKEN&response=transparent The link carries a credential and executes with it, so it is as sensitive as the credential itself — and it passes through the cURL service's request log on the way, not just the target's. Share it only where you would share the secret, and prefer a delegated token with minimal permissions and an expiry: see API tokens.
The response lists each token’s settings:
{ "statusCode": 200, "message": "Auth tokens retrieved successfully", "data": [ { "id": "63f8b0e5c9a1b2d3e4f5a6b7", "alias": "CI-CD Pipeline", "prefix": "hdy_", "ip_whitelist": ["203.0.113.50"], "expires_at": "2026-02-07T15:00:00.000Z", "is_enabled": true, "last_used_at": null, "last_used_ip": null, "created_at": "2025-11-09T10:00:00.000Z", "updated_at": "2025-11-09T10:00:00.000Z" } ]}Audit your tokens:
- Check
expires_atandis_enabledon each token - Review
ip_whitelistrestrictions last_used_atandlast_used_ipare alwaysnull: token usage is not reported
Revoke tokens
Section titled “Revoke tokens”Disable without deleting:
# Disable a tokenhoody auth tokens update $TOKEN_ID --no-is-enabled
# Re-enable ithoody auth tokens update $TOKEN_ID --is-enabledawait client.api.auth.tokens.update(tokenId, { is_enabled: false });curl -X PATCH "https://api.hoody.com/api/v1/auth/tokens/$TOKEN_ID" \ -H "Authorization: Bearer $HOODY_TOKEN" \ -H "Content-Type: application/json" \ -d '{"is_enabled": false}'One request, one link
cURL runs inside your container and can wrap any HTTP request into a single GET URL. The call stops being something you need a client for and becomes something you can paste into a browser, send in a chat, bookmark, schedule with cron, or drop into a no-code tool.
Nothing is installed on the machine that opens it. The link does carry whatever credentials the call needs, so treat it as you would treat those credentials.
Slashes, colons and braces pass through as they are. The one character you must
encode is an & inside a value, which happens when the wrapped URL
carries its own query string. Left raw it ends the value early, and the rest is
read as cURL's own parameters, so you get a 200 on a request you did
not make.
How the wrapping works Chaining calls into one link Turning a link into a shortcut
Disable a token without deleting it, so it stops working immediately but can be re-enabled later under the same ID.
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://api.hoody.com/api/v1/auth/tokens/TOKEN_ID&method=PATCH&bearer_token=HOODY_TOKEN&json={"is_enabled":false}&response=transparent The link carries a credential and executes with it, so it is as sensitive as the credential itself — and it passes through the cURL service's request log on the way, not just the target's. Share it only where you would share the secret, and prefer a delegated token with minimal permissions and an expiry: see API tokens.
Permanently delete:
# Permanently delete a tokenhoody auth tokens delete $TOKEN_IDawait client.api.auth.tokens.delete(tokenId);curl -X DELETE "https://api.hoody.com/api/v1/auth/tokens/$TOKEN_ID" \ -H "Authorization: Bearer $HOODY_TOKEN"One request, one link
cURL runs inside your container and can wrap any HTTP request into a single GET URL. The call stops being something you need a client for and becomes something you can paste into a browser, send in a chat, bookmark, schedule with cron, or drop into a no-code tool.
Nothing is installed on the machine that opens it. The link does carry whatever credentials the call needs, so treat it as you would treat those credentials.
Slashes, colons and braces pass through as they are. The one character you must
encode is an & inside a value, which happens when the wrapped URL
carries its own query string. Left raw it ends the value early, and the rest is
read as cURL's own parameters, so you get a 200 on a request you did
not make.
How the wrapping works Chaining calls into one link Turning a link into a shortcut
Permanently delete a token. This cannot be undone.
https://PROJECT_ID-CONTAINER_ID-curl-1.SERVER.containers.hoody.com/api/v1/curl/request?url=https://api.hoody.com/api/v1/auth/tokens/TOKEN_ID&method=DELETE&bearer_token=HOODY_TOKEN&response=transparent The link carries a credential and executes with it, so it is as sensitive as the credential itself — and it passes through the cURL service's request log on the way, not just the target's. Share it only where you would share the secret, and prefer a delegated token with minimal permissions and an expiry: see API tokens.
Security best practices
Section titled “Security best practices”Never hardcode credentials
Section titled “Never hardcode credentials”// Do not do thisconst response = await fetch('https://api.hoody.com/api/v1/projects/', { headers: { 'Authorization': 'Bearer hdy_abc123hardcoded' }});// Use environment variablesconst response = await fetch('https://api.hoody.com/api/v1/projects/', { headers: { 'Authorization': `Bearer ${process.env.HOODY_TOKEN}` }});Use IP whitelisting
Section titled “Use IP whitelisting”Restrict a token to specific sources:
If the token leaks, it will not work from any other IP.
Set expiration dates
Section titled “Set expiration dates”Short-lived, for temporary access:
{ "alias": "Contractor Access", "expires_at": "2027-05-12T00:00:00Z", "permission_template": "full_access"}Long-lived, for permanent infrastructure:
{ "alias": "Production Services", "expires_at": "2027-04-12T00:00:00Z", "permission_template": "full_access"}Review and rotate tokens regularly.
Create one token per service
Section titled “Create one token per service”Do not share a single token across systems:
# Create separate tokensPOST /api/v1/auth/tokens { "alias": "GitHub Actions CI", ... }POST /api/v1/auth/tokens { "alias": "Monitoring System", ... }POST /api/v1/auth/tokens { "alias": "AI Agent Orchestrator", ... }If one service is compromised, revoke only that token; the others keep working.
Review your sign-in history
Section titled “Review your sign-in history”Every successful sign-in to your account is recorded: the IP address, the country it resolved to, the client channel, and the timestamp. GET /api/v1/users/me/security-history returns your own trail, newest first. Pass include_failed=true to add rejected attempts against your account, and include_security=true to add other security events such as 2FA changes, password resets, and token creation or revocation. A burst of failed attempts, or a sign-in from a country you have never visited, is the clearest sign someone else is trying to get in.
The trail is append-only and kept for 180 days: you cannot delete entries, and neither can anyone who gets into your account. What is recorded, what is not, and how long it is kept is covered in Auditing & Data Collection.
AI agents
Section titled “AI agents”The Auth Token system is built for AI orchestration:
// AI agent configuration (environment variables)const HOODY_TOKEN = process.env.HOODY_TOKEN; // hdy_... tokenconst HOODY_API = 'https://api.hoody.com';
// AI can now orchestrate infrastructureasync function aiAgentWorkflow(task) { const headers = { 'Authorization': `Bearer ${HOODY_TOKEN}`, 'Content-Type': 'application/json' };
// AI decides: "Need a container to process this task" const container = await fetch(`${HOODY_API}/api/v1/projects/${projectId}/containers`, { method: 'POST', headers, body: JSON.stringify({ name: `ai-task-${Date.now()}`, server_id: 'your-server-id', hoody_kit: true }) }).then(r => r.json());
// Wait for container to be running await waitForStatus(container.data.id, 'running');
// Get container URLs and use them const terminalUrl = `https://${projectId}-${container.data.id}-terminal-1.${container.data.server_name}.containers.hoody.com`;
// AI executes commands in the new container await fetch(`${terminalUrl}/api/v1/terminal/execute`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ command: task.command }) });
// AI snapshots when done await fetch(`${HOODY_API}/api/v1/containers/${container.data.id}/snapshots`, { method: 'POST', headers, body: JSON.stringify({ alias: `task-${task.id}` }) });
return container;}The agent needs two things:
- A Hoody Auth Token in an environment variable
- An understanding of HTTP, which a model already has
There is no SDK to install and no training step. The interface is HTTP.
Token comparison
Section titled “Token comparison”| Feature | JWT (Login) | Auth Token (hdy_…) |
|---|---|---|
| Lifetime | 1 day (access) 7 days (refresh) | Configurable (ISO 8601 date, “today”, “tomorrow”, or forever) |
| Use case | User sessions | Automation, AI, scripts |
| Refresh | Yes (via refresh token) | No (create a new one when expired) |
| IP whitelist | No | Yes (optional) |
| Revocation | Logout, called with a JWT, revokes all previously issued session JWTs for that owner | Delete or disable |
| Visibility | Managed by browser | Shown once, at creation |
| Security | Short lifetime limits exposure | Long lifetime, bounded by IP whitelist |
Complete authentication examples
Section titled “Complete authentication examples”Log in as a user
Section titled “Log in as a user”# 1. LoginLOGIN_RESPONSE=$(curl -X POST "https://api.hoody.com/api/v1/users/auth/login" \ -H "Content-Type: application/json" \ -d '{ "username": "dev_user", "password": "strong_password_here" }')
# Response includes tokens# {# "data": {# "token": "eyJhbG...",# "refreshToken": "eyJhbG...",# "user": { ... }# }# }
# Capture the access and refresh tokens for steps 2-4ACCESS_TOKEN=$(printf '%s' "$LOGIN_RESPONSE" | jq -r '.data.token')REFRESH_TOKEN=$(printf '%s' "$LOGIN_RESPONSE" | jq -r '.data.refreshToken')
# 2. Use access token (valid 1 day)curl "https://api.hoody.com/api/v1/projects/" \ -H "Authorization: Bearer $ACCESS_TOKEN"
# 3. Refresh before expiration (within 7 days)# The refresh token goes in both the header and the body, and the two must matchcurl -X POST "https://api.hoody.com/api/v1/users/auth/refresh" \ -H "Content-Type: application/json" \ -H "Authorization: Bearer $REFRESH_TOKEN" \ -d '{"refreshToken": "'"$REFRESH_TOKEN"'"}'
# 4. Logout: revokes every session JWT issued to this owner so far, this one includedcurl -X POST "https://api.hoody.com/api/v1/users/auth/logout" \ -H "Authorization: Bearer $ACCESS_TOKEN"Create an automation token
Section titled “Create an automation token”# 1. Login to get JWTcurl -X POST "https://api.hoody.com/api/v1/users/auth/login" \ -H "Content-Type: application/json" \ -d '{"username": "your_username", "password": "your_password"}' \ > login.json
# Extract JWTJWT=$(cat login.json | jq -r '.data.token')
# 2. Create auth token for CI/CDcurl -X POST "https://api.hoody.com/api/v1/auth/tokens" \ -H "Authorization: Bearer $JWT" \ -H "Content-Type: application/json" \ -d '{ "alias": "GitHub Actions Deployment", "ip_whitelist": ["140.82.112.0/20"], "permission_template": "full_access", "expires_at": "2027-04-12T00:00:00Z" }' > token.json
# Extract auth tokenAUTH_TOKEN=$(cat token.json | jq -r '.data.token')
# 3. Save to GitHub Secrets as HOODY_TOKENecho "HOODY_TOKEN=$AUTH_TOKEN"
# 4. Use in GitHub Actions workflow# - name: Deploy via Hoody API# env:# HOODY_TOKEN: ${{ secrets.HOODY_TOKEN }}# run: |# curl -X POST "https://api.hoody.com/api/v1/containers/$CONTAINER_ID/start" \# -H "Authorization: Bearer $HOODY_TOKEN"Set up an AI agent
Section titled “Set up an AI agent”// AI agent configuration fileHOODY_TOKEN=hdy_abc123def456...HOODY_PROJECT_ID=63f8b0e5c9a1b2d3e4f5a6b7
// agent.jsimport 'dotenv/config';
class HoodyAgent { constructor() { this.token = process.env.HOODY_TOKEN; this.projectId = process.env.HOODY_PROJECT_ID; this.api = 'https://api.hoody.com'; }
async callAPI(endpoint, options = {}) { return fetch(`${this.api}${endpoint}`, { ...options, headers: { 'Authorization': `Bearer ${this.token}`, 'Content-Type': 'application/json', ...options.headers } }); }
async spawnContainer(name, config) { const response = await this.callAPI( `/api/v1/projects/${this.projectId}/containers`, { method: 'POST', body: JSON.stringify({ name, server_id: config.serverId, hoody_kit: true, ...config }) } ); return response.json(); }
async executeInContainer(containerId, command) { // First get container details to construct service URL const container = await this.callAPI(`/api/v1/containers/${containerId}`) .then(r => r.json());
// Construct terminal URL const terminalUrl = `https://${this.projectId}-${containerId}-terminal-1.${container.data.server_name}.containers.hoody.com`;
// Execute command (no auth needed if container permissions are open) return fetch(`${terminalUrl}/api/v1/terminal/execute`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ command }) }); }}
// AI uses this class for every Hoody operationconst agent = new HoodyAgent();await agent.spawnContainer('ai-workspace', { serverId: 'server-123' });The agent only needs environment variables. It never handles a password or manages credentials.
Token updates and rotation
Section titled “Token updates and rotation”List all tokens
Section titled “List all tokens” Update a token
Section titled “Update a token”Change the IP whitelist:
Extend the expiration:
Temporarily disable:
Rotate a token
Section titled “Rotate a token”Production rotation:
# 1. Create new tokenNEW_TOKEN=$(curl -X POST "https://api.hoody.com/api/v1/auth/tokens" \ -H "Authorization: Bearer YOUR_JWT" \ -H "Content-Type: application/json" \ -d '{"alias": "Production V2", "permission_template": "full_access", "expires_at": "2027-04-12T00:00:00Z"}' \ | jq -r '.data.token')
# 2. Update your services with new token# (Deploy new environment variable to all services)
# 3. Disable the old token (reversible) and confirm every service still workscurl -X PATCH "https://api.hoody.com/api/v1/auth/tokens/{old_token_id}" \ -H "Authorization: Bearer YOUR_JWT" \ -H "Content-Type: application/json" \ -d '{"is_enabled": false}'
# 4. Delete old tokencurl -X DELETE "https://api.hoody.com/api/v1/auth/tokens/{old_token_id}" \ -H "Authorization: Bearer YOUR_JWT"Common patterns
Section titled “Common patterns”Short-lived scripts
Section titled “Short-lived scripts”For one-time operations:
Use the token for your migration, and it expires on its own tomorrow.
Per-environment tokens
Section titled “Per-environment tokens”A different token for each environment:
Development token (permissive):
Staging token (IP-restricted):
Production token (strict):
Emergency revocation
Section titled “Emergency revocation”If a token is compromised:
1. Disable it immediately:
2. Create a replacement with a different IP whitelist:
3. Update your services, then delete the old token:
Useful questions
Section titled “Useful questions”Should I use JWT tokens or Auth Tokens for my scripts?
Section titled “Should I use JWT tokens or Auth Tokens for my scripts?”Use Auth Tokens (hdy_...) for scripts and automation. JWTs from login are meant for short-lived user sessions and expire after 1 day. An Auth Token can live for years, with IP whitelisting and revocation.
Can I create an Auth Token using another Auth Token?
Section titled “Can I create an Auth Token using another Auth Token?”Yes, if the parent token holds the resources.create_tokens permission. Such a token can mint child tokens whose permissions are clamped to a subset of its own (each child’s delegation_depth is the parent’s plus one; 0 means account-minted from a JWT). Tokens without resources.create_tokens cannot create more tokens, and that includes any token created with deny_reauthorization: true, which strips the permission. Leave resources.create_tokens off for leaf tokens you hand to scripts.
How do I give my team API access without passwords?
Section titled “How do I give my team API access without passwords?”Create individual Auth Tokens for each team member with specific IP whitelists. Each person gets their own hdy_... token, and you can revoke any token independently if someone leaves the team.
How do I check whether someone else accessed my account?
Section titled “How do I check whether someone else accessed my account?”Call GET /api/v1/users/me/security-history. Each entry carries the event, a success or failed outcome, the IP address, the resolved country (null until resolved, and null permanently for addresses that cannot be located), the source channel, and the timestamp. Pass include_failed=true to include rejected sign-in attempts; those are only recorded when a credential was actually checked against your account, so the endpoint cannot be used to probe whether an account exists.
What happens when my JWT access token expires?
Section titled “What happens when my JWT access token expires?”After 1 day, the access token expires. Use your refresh token (valid 7 days) to get a new access token via POST /api/v1/users/auth/refresh. If the refresh token also expires, log in again with username and password.
Can one Auth Token cover several servers or apps?
Section titled “Can one Auth Token cover several servers or apps?”Yes, but treat it as a convenience rather than a practice to standardize on. One token can work across several projects and servers; separate tokens per app or environment isolate better.
For realm-restricted tokens:
- If
realm_idsis non-empty (orallow_no_realm: false), use realm-scoped hosts likehttps://{realmId}.api.hoody.com. - Use
GET /api/v1/auth/tokens/meonhttps://api.hoody.comto discover allowed realms before selecting a realm host.
How secure are Auth Tokens with no IP whitelist?
Section titled “How secure are Auth Tokens with no IP whitelist?”Without an IP whitelist, a leaked token works from anywhere. The token itself is cryptographically strong (a long random string), but the whitelist adds defense-in-depth. Use it for production tokens and skip it for low-sensitivity automation.
Can Auth Tokens expire while my script is running?
Section titled “Can Auth Tokens expire while my script is running?”Yes. If a long-running script spans the expiration time, it starts getting 401 errors. For long processes, use a far-future ISO 8601 date, omit expires_at at creation so the token never expires, or add logic that creates a replacement token before the old one expires.
What is the difference between disable and delete?
Section titled “What is the difference between disable and delete?”Disabling sets is_enabled: false. The token stops working, and you can re-enable it later under the same ID. Deleting removes the token permanently, and it cannot be recovered. Use disable for a temporary suspension and delete for permanent revocation.
Can I use Auth Tokens with realm-scoped APIs?
Section titled “Can I use Auth Tokens with realm-scoped APIs?”Yes. Auth Tokens work with realm-scoped APIs ({realmId}.api.hoody.com), and unrestricted tokens can also use the base API host.
Important behavior:
- Tokens with non-empty
realm_idsare restricted to those realm IDs. - Tokens with
allow_no_realm: falsecannot use the base host for resource operations. - Realm-restricted tokens can still call
GET /api/v1/auth/tokens/meon the base host to bootstrap realm discovery.
Why does my token see environment variable names but null values?
Section titled “Why does my token see environment variable names but null values?”The token lacks containers.features.env_values. Without it, GET /api/v1/containers/{id}/env and the set and delete operations list every key with a null value and values_redacted: true; a logged-in session always sees values. The full_access and dev_team templates grant the permission, and an explicit permission tree needs "containers": { "features": { "env_values": true } }. The permission only controls what those endpoints return: anyone who can run commands in the container or read its files can still read the environment there. See Container environment variables.
How do I rotate Auth Tokens for zero-downtime updates?
Section titled “How do I rotate Auth Tokens for zero-downtime updates?”Create the new token, deploy it to your services and verify it works. Then disable the old token, confirm nothing breaks, and delete it. Both tokens work at the same time until you disable the old one.
Troubleshooting
Section titled “Troubleshooting”Login fails with invalid credentials
Section titled “Login fails with invalid credentials”Problem: login returns 401 with “Invalid credentials”
Solutions:
- Verify username/password:
- Check account status:
- The account may be banned (
is_banned: true) - Contact support if the account should be active
- The account may be banned (
JWT access token expired
Section titled “JWT access token expired”Problem: requests return 401 after some time
Cause: the access token expires after 1 day
Solution: Use the refresh token.
# The refresh token goes in both the header and the body, and the two must matchcurl -X POST "https://api.hoody.com/api/v1/users/auth/refresh" \ -H "Content-Type: application/json" \ -H "Authorization: Bearer $REFRESH_TOKEN" \ -d '{"refreshToken": "'"$REFRESH_TOKEN"'"}'This returns a new access token and a new refresh token. If the refresh token has also expired, after 7 days, log in again.
Auth Token not working
Section titled “Auth Token not working”Problem: an hdy_... token returns 403 Forbidden
Check IP whitelist:
Compare the ip_whitelist array with your current IP (run curl https://ifconfig.me in terminal).
Solution: Update the whitelist.
Token creation returns 403
Section titled “Token creation returns 403”Problem: creating an Auth Token with another Auth Token returns 403 This token does not have permission: resources.create_tokens (auth tokens cannot manage auth tokens without it)
Cause: the calling token does not hold the resources.create_tokens permission. Tokens created with deny_reauthorization: true never hold it.
Solution: Grant resources.create_tokens when you create the parent token, or mint the child from a JWT obtained via login:
# 1. Login firstcurl -X POST "https://api.hoody.com/api/v1/users/auth/login" \ -H "Content-Type: application/json" \ -d '{"username": "your_username", "password": "your_password"}' \ > login.json
# 2. Extract JWTJWT=$(cat login.json | jq -r '.data.token')
# 3. Create Auth Token with JWTcurl -X POST "https://api.hoody.com/api/v1/auth/tokens" \ -H "Authorization: Bearer $JWT" \ -H "Content-Type: application/json" \ -d '{"alias": "My Token", "expires_at": "2027-04-12T00:00:00Z", "permission_template": "full_access"}'Lost Auth Token value
Section titled “Lost Auth Token value”Problem: the token was created but the hdy_... value was not saved
Reality: the token value cannot be retrieved after creation
Solution:
- Disable the old token:
- Create a new token:
Automation fails intermittently
Section titled “Automation fails intermittently”Problem: scripts work sometimes and fail other times with 403
Likely cause: an IP whitelist combined with a dynamic IP
Check if your IP changed:
Run curl https://ifconfig.me in a terminal to get your current IP, then compare it with the token whitelist:
Solutions:
-
Use a CIDR range instead of a single IP: Instead of
"203.0.113.50/32", use"203.0.113.0/24", which allows the whole subnet -
Remove the IP whitelist for non-sensitive automation:
To lift IP restrictions, set ip_whitelist to ["*"], which allows all. An empty array is rejected with 400, because the array form requires at least one entry.
- Use a static IP for automation servers
What’s Next
Section titled “What’s Next”- Create Projects - Organize your containers
- Spawn Containers - Create your first HTTP computer
- Configure Networking - Set up routing and firewall
- Create Proxy Aliases - Get clean URLs for production