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/logout - Invalidate session
- 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 auth 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.authentication.login({ email: 'you@example.com', password: 'your_password'});console.log(auth.data.token); // JWT access tokenconsole.log(auth.data.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" } }}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: auth.data.token});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
# CLI handles token refresh automatically# If your session expired, simply re-loginhoody auth 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: auth.data.refreshToken});const refreshed = await refreshClient.api.authentication.refreshToken({ refreshToken: auth.data.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.
Automation and AI agents (recommended)
Section titled “Automation and AI agents (recommended)”Step 1: Create an Auth Token (one-time setup)
# Login firsthoody auth login --username your_username --password your_password
# Create a long-lived automation token with IP whitelisthoody auth create \ --alias "Production Automation Token" \ --ip-whitelist "203.0.113.10,203.0.113.20" \ --expires-at "2027-04-12T00:00:00Z"import { HoodyClient } from 'hoody-sdk';
// Login first to get JWTconst client = new HoodyClient({ baseURL: 'https://api.hoody.com' });const auth = await client.api.authentication.login({ username: 'your_username', password: 'your_password'});
// Create auth token using JWTconst jwtClient = new HoodyClient({ baseURL: 'https://api.hoody.com', token: auth.data.token });const token = await jwtClient.api.authTokens.create({ alias: 'Production Automation Token', ip_whitelist: ['203.0.113.10', '203.0.113.20'], 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"], "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"}&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"], "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" }}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
- An audit trail in
last_used_atandlast_used_ip
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:
expires_at accepts several formats:
{ "expires_at": "2026-12-31T23:59:59Z"}{ "expires_at": 1735689599}{ "expires_at": "tomorrow"}Accepts today (expires at midnight tonight) or tomorrow (expires in 24 hours).
{ "alias": "Production Services"}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 listconst tokens = await client.api.authTokens.list();tokens.data.forEach(t => { console.log(t.alias, t.is_enabled, t.last_used_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 usage and whitelist details for each.
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 shows usage tracking:
{ "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": "2025-11-09T14:30:00.000Z", "last_used_ip": "203.0.113.50", "created_at": "2025-11-09T10:00:00.000Z", "updated_at": "2025-11-09T14:30:00.000Z" } ]}Audit your tokens:
- Check
last_used_atto identify unused tokens - Verify
last_used_ipmatches expected sources - Review
ip_whitelistrestrictions
Revoke tokens
Section titled “Revoke tokens”Disable without deleting:
# The CLI cannot disable tokens: '--is-enabled' is presence-only and always sets true.# Use the SDK or HTTP tab to disable. To re-enable a disabled token:hoody auth update $TOKEN_ID --is-enabledawait client.api.authTokens.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 delete $TOKEN_IDawait client.api.authTokens.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": "2026-05-12T00:00:00Z"}Long-lived, for permanent infrastructure:
{ "alias": "Production Services", "expires_at": "2027-04-12T00:00:00Z"}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', 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 endpoint | 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 (optional, invalidates session)curl -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" \ -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"], "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 "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', 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" \ -d '{"alias": "Production V2", "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. Wait 24-48 hours for old token usage to drop
# 4. Check old token is unusedcurl "https://api.hoody.com/api/v1/auth/tokens/{old_token_id}" \ -H "Authorization: Bearer YOUR_JWT" \ | jq '.data.last_used_at'
# 5. 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. For Auth Tokens, last_used_at and last_used_ip give the same signal per token.
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.
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, verify it works, wait 24-48 hours, check that the old token’s last_used_at has stopped moving, then delete the old token. Both tokens work at the same time during the transition.
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 Auth tokens cannot manage auth tokens
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" \ -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" \ -d '{"alias": "My Token", "expires_at": "2027-04-12T00:00:00Z"}'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