UptimeHunt Docs
API Reference

API Authentication

The UptimeHunt API uses JWT (JSON Web Token) based authentication to secure all API endpoints.

API Authentication

Overview

The UptimeHunt API uses JWT (JSON Web Token) based authentication to secure all API endpoints. This guide provides comprehensive documentation for implementing authentication in your applications, including token management, user registration, login workflows, and password reset functionality.

Authentication Flow

Token Types

UptimeHunt issues two types of tokens:

Token TypeLifetimePurpose
Access Token15 minutesAuthenticates API requests
Refresh Token7 daysGenerates new access tokens

Token Management Workflow

  1. Initial Authentication: User registers or logs in to receive both access and refresh tokens
  2. API Requests: Include access token in the Authorization header for authenticated requests
  3. Token Refresh: When access token expires, use refresh token to obtain new access token
  4. Re-authentication: When refresh token expires, user must log in again

Base URL

All API endpoints use the following base URL:

https://app.uptimehunt.io/api

Authentication Endpoints

User Registration

Create a new user account. Registration is followed by email verification — the account stays inactive until the emailed link is confirmed, and tokens are obtained by logging in afterwards.

Endpoint: POST /v1/auth/register

Authentication: Not required

Request Headers:

Content-Type: application/json

Request Body:

{
  "email": "user@example.com",
  "password": "securepassword123",
  "first_name": "John",
  "last_name": "Doe"
}

Request Schema:

FieldTypeRequiredDescription
emailstringYesValid email address (at most one password account per email)
passwordstringYesPassword (minimum 8 characters)
first_namestringNoUser's first name
last_namestringNoUser's last name
account_typestringNopersonal (default) or team — selects the sign-up path (see below)
org_namestringTeam onlyDisplay name of the new team organization; required when account_type is team. The organization's URL slug is derived server-side
invite_tokenstringNoInvitation sign-up: the token from an organization invitation email (see below)

Personal vs. team sign-up

account_type: "personal" (the default — existing API consumers are unaffected) creates an individual account with its own personal workspace. account_type: "team" creates the account and only the team organization named by org_name — the creator becomes its Owner and the organization starts a 14-day trial. The founder is an organization-only account with no personal workspace (no personal resource or token side-channel outside the organization's governance); see Organization Context for how such accounts resolve their active organization. A team organization can also be created after a personal sign-up with POST /v1/orgs — in that case you keep your personal workspace. See Creating a Team Organization.

Invitation sign-up

When invite_token carries a valid pending organization invitation, registration completes in one step: the account is created, the email is marked verified (no verification email is sent — the invitation was delivered to that address), and the membership is created with the invited role, all in the same transaction. The response is the distinct REGISTERED body shown below — not the generic VERIFICATION_REQUIRED response — and the user can log in immediately. The submitted email must equal the invitation email — a sign-up with a different address is rejected, as is an invalid, expired, or already-used token (exact status codes and messages in the error table below). Accounts created this way are organization-only (no personal workspace), like team founders; account_type and org_name do not apply on this path.

Success Response (201 Created — personal and team sign-up):

{
  "status": "VERIFICATION_REQUIRED",
  "message": "If the address is new, an account was created. Check your email for a verification link to activate it, then sign in.",
  "email": "user@example.com"
}

Anti-enumeration

On the personal and team paths, the endpoint returns this same 201 response whether or not the email was already registered — it never reveals that an account exists. If the address already has an unverified account, the verification email is re-sent (cooldown-limited); a verified account receives nothing.

The invitation path is deliberately not anti-enumerating: the caller already holds the invitation token for that address, so its precise errors (below) reveal nothing they don't already know.

Success Response (201 Created — invitation sign-up):

The invitation path returns a different body: status is REGISTERED, because the account is already verified and active — there is no verification email to check. Send the user straight to login.

{
  "status": "REGISTERED",
  "message": "Account created and invitation accepted — you can sign in now.",
  "email": "invitee@acme.example",
  "org": {
    "id": 7,
    "slug": "acme",
    "name": "Acme Corporation",
    "org_type": "team",
    "logo_url": "",
    "url": "",
    "contact_email": "",
    "enforced_sso": false,
    "plan": null,
    "billing_state": "trial",
    "trial_ends_at": "2026-06-24T10:30:00Z",
    "created_at": "2026-05-20T09:00:00Z",
    "role": "member",
    "capabilities": ["member.view", "org.view", "service.view", "..."]
  }
}

org is the organization the account just joined, in the same shape GET /v1/orgs returns it — including the new member's role and effective capabilities.

Error Responses:

Status CodeErrorDescription
400Email and password are requiredMissing required fields
400account_type must be one of: personal, teamUnknown account_type value
400org_name is required for team signupaccount_type is team but no organization name was provided

Invitation sign-up (invite_token present) adds these:

Status CodeErrorDescription
400Invalid invitation tokeninvite_token does not match any invitation
400The email address must match the invitation — sign up with the address the invitation was sent toSubmitted email differs from the invitation's email
400invite_token cannot be combined with a team signup — the invitation already determines the organizationBoth invite_token and account_type: "team" were sent
403Organization member limit reached (N)The invitation's organization is at its plan's member limit (N is the limit)
409This invitation was already usedThe invitation was already accepted
409An account with this email already exists — sign in with it and accept the invitation insteadA password account with this email exists — log in with it and use the authenticated accept endpoint
410This invitation has expiredThe invitation passed its expiry — ask an organization Owner or Admin to send a new one

cURL Example:

curl -X POST https://app.uptimehunt.io/api/v1/auth/register \
  -H "Content-Type: application/json" \
  -d '{
    "email": "user@example.com",
    "password": "securepassword123",
    "first_name": "John",
    "last_name": "Doe"
  }'

cURL Example (team sign-up):

curl -X POST https://app.uptimehunt.io/api/v1/auth/register \
  -H "Content-Type: application/json" \
  -d '{
    "email": "founder@acme.example",
    "password": "securepassword123",
    "account_type": "team",
    "org_name": "Acme Corporation"
  }'

cURL Example (invitation sign-up):

curl -X POST https://app.uptimehunt.io/api/v1/auth/register \
  -H "Content-Type: application/json" \
  -d '{
    "email": "invitee@acme.example",
    "password": "securepassword123",
    "invite_token": "token_from_the_invitation_email"
  }'

User Login

Authenticate existing user and receive authentication tokens.

Endpoint: POST /v1/auth/login

Authentication: Not required

Request Headers:

Content-Type: application/json

Request Body:

{
  "email": "user@example.com",
  "password": "securepassword123"
}

Request Schema:

FieldTypeRequiredDescription
emailstringYesUser's email address
passwordstringYesUser's password

Success Response (200 OK):

{
  "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "token_type": "Bearer",
  "expires_in": 900,
  "user": {
    "id": 1,
    "email": "user@example.com",
    "first_name": "John",
    "last_name": "Doe",
    "date_joined": "2024-01-15T10:30:00Z"
  }
}

Error Responses:

Status CodeErrorDescription
400Must include "email" and "password"Missing required fields
400Invalid credentialsIncorrect email or password
400User account is disabledAccount has been deactivated
403SSO_REQUIREDThe account belongs to an organization with enforced SSO — password login is refused; authenticate through the organization's identity provider instead

Email-first login and SSO

The same email address may have multiple sign-in routes (a password account and/or organization SSO accounts — they are separate accounts). Use the SSO lookup endpoint to discover the routes before prompting for a password.

The SSO_REQUIRED Payload

Every endpoint that refuses password authentication under enforced SSO — login, token refresh, password reset and password change — returns the same structured 403 body:

{
  "code": "SSO_REQUIRED",
  "message": "Your organization requires single sign-on. Sign in through your identity provider.",
  "org": "acme",
  "org_slug": "acme",
  "redirect_url": "https://auth.uptimehunt.io/accounts/oidc/acme/login/?process=login",
  "idps": [
    {
      "org_slug": "acme",
      "label": "Acme Corporation",
      "redirect_url": "https://auth.uptimehunt.io/accounts/oidc/acme/login/?process=login",
      "enforced": true
    }
  ]
}
FieldDescription
org_slugThe enforcing organization's slug — always present. When redirect_url is null, fall back to the org_slug form of the lookup endpoint, or at least name the organization in a "contact your administrator" message
orgThe same value as org_slug, kept for existing consumers
redirect_urlThe organization's IdP entry URL — navigate the browser there to sign in. null when the organization enforces SSO but its identity provider is missing or disabled; password authentication is refused regardless
idpsThe enforcing organization's sign-in route in the exact lookup route shape (org_slug, label, redirect_url, enforced). The payload is self-sufficient: render the "Continue with single sign-on" action from this response alone — no separate lookup call and no previously stored lookup result needed. Contains exactly one route when the organization's identity provider is enabled; [] exactly when redirect_url is null (so an empty idps is the structured "no route available — contact your administrator" signal; a route object never appears with a null URL)
idps[].labelThe organization's display name — use it to label the SSO button
idps[].enforcedAlways true here — this payload only fires for an organization that enforces SSO

cURL Example:

curl -X POST https://app.uptimehunt.io/api/v1/auth/login \
  -H "Content-Type: application/json" \
  -d '{
    "email": "user@example.com",
    "password": "securepassword123"
  }'

Refresh Access Token

Generate a new access token using a valid refresh token.

Endpoint: POST /v1/auth/refresh

Authentication: Not required (uses refresh token)

Request Headers:

Content-Type: application/json

Request Body:

{
  "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}

Request Schema:

FieldTypeRequiredDescription
refresh_tokenstringYesValid refresh token

Success Response (200 OK):

{
  "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "token_type": "Bearer",
  "expires_in": 900
}

Error Responses:

Status CodeErrorDescription
400Refresh token is requiredMissing refresh token
401Invalid refresh tokenToken is invalid, expired, malformed, or has been revoked
403SSO_REQUIREDThe account belongs to an enforced-SSO organization and this refresh token predates enforcement — re-authenticate via SSO

Refresh-token rotation and revocation

Refresh tokens are rotated: each refresh invalidates the used token and the response includes a new refresh_token — always store the returned pair. Outstanding refresh tokens are also revoked server-side on organization events such as member removal or role downgrade and when enforced SSO is enabled.

cURL Example:

curl -X POST https://app.uptimehunt.io/api/v1/auth/refresh \
  -H "Content-Type: application/json" \
  -d '{
    "refresh_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
  }'

Get Current User

Retrieve authenticated user information.

Endpoint: GET /v1/auth/me

Authentication: Required

Request Headers:

Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...

Success Response (200 OK):

{
  "data": {
    "id": 1,
    "email": "user@example.com",
    "first_name": "John",
    "last_name": "Doe",
    "date_joined": "2024-01-15T10:30:00Z",
    "realm": {"type": "password"},
    "default_org_slug": "u-1",
    "orgs": [
      {
        "id": 7,
        "slug": "acme",
        "name": "Acme Corporation",
        "org_type": "team",
        "role": "admin",
        "capabilities": ["org.view", "member.view", "member.invite", "audit.view", "service.view", "service.create", "service.edit", "service.delete", "..."]
      }
    ]
  }
}
FieldDescription
realmThe account's identity realm: {"type": "password"}, {"type": "social", "provider": "github"} for social sign-in accounts, or {"type": "sso", "org_slug": "...", "idp_label": "..."} for SSO-provisioned accounts
orgsAll organizations the account belongs to, with its role and effective capabilities in each
default_org_slugThe organization used when a request carries no organization context

Error Responses:

Status CodeErrorDescription
401Authentication credentials were not providedMissing Authorization header
401Given token not valid for any token typeInvalid or expired access token

cURL Example:

curl -X GET https://app.uptimehunt.io/api/v1/auth/me \
  -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."

Update User Profile

Update authenticated user's profile information.

Endpoint: PUT /v1/auth/profile or PATCH /v1/auth/profile

Authentication: Required

Request Headers:

Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
Content-Type: application/json

Request Body (all fields optional for PATCH):

{
  "email": "newemail@example.com",
  "first_name": "Jane",
  "last_name": "Smith"
}

Request Schema:

FieldTypeRequiredDescription
emailstringNoNew email address (must be unique)
first_namestringNoUpdated first name
last_namestringNoUpdated last name

Success Response (200 OK):

{
  "data": {
    "id": 1,
    "email": "newemail@example.com",
    "first_name": "Jane",
    "last_name": "Smith",
    "date_joined": "2024-01-15T10:30:00Z"
  },
  "message": "Profile updated successfully"
}

Error Responses:

Status CodeErrorDescription
400A user with this email already existsEmail already in use
401Authentication credentials were not providedMissing or invalid token

cURL Example:

curl -X PUT https://app.uptimehunt.io/api/v1/auth/profile \
  -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." \
  -H "Content-Type: application/json" \
  -d '{
    "first_name": "Jane",
    "last_name": "Smith"
  }'

Change Password

Change authenticated user's password.

Endpoint: POST /v1/auth/change-password

Authentication: Required

Request Headers:

Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
Content-Type: application/json

Request Body:

{
  "current_password": "oldpassword123",
  "new_password": "newsecurepassword456"
}

Request Schema:

FieldTypeRequiredDescription
current_passwordstringYesCurrent password for verification
new_passwordstringYesNew password (minimum 8 characters)

Success Response (200 OK):

{
  "status": "PASSWORD_CHANGED",
  "message": "Password changed successfully"
}

Error Responses:

Status CodeErrorDescription
400Current password is incorrectWrong current password provided
400This field is requiredMissing required field
400Ensure this field has at least 8 charactersPassword too short
401Authentication credentials were not providedMissing or invalid token
403SSO_REQUIREDThe account belongs to an organization with enforced SSO — local passwords are disabled by policy, so they cannot be changed (the same gate as login and password reset)

cURL Example:

curl -X POST https://app.uptimehunt.io/api/v1/auth/change-password \
  -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." \
  -H "Content-Type: application/json" \
  -d '{
    "current_password": "oldpassword123",
    "new_password": "newsecurepassword456"
  }'

Security Notice

A confirmation email is sent to the user after successfully changing their password.


Request Password Reset

Request a password reset link via email.

Endpoint: POST /v1/auth/password-reset/request

Authentication: Not required

Request Headers:

Content-Type: application/json

Request Body:

{
  "email": "user@example.com",
  "frontend_url": "https://app.uptimehunt.io"
}

Request Schema:

FieldTypeRequiredDescription
emailstringYesEmail address of the account
frontend_urlstringNoBase URL for password reset link (defaults to configured frontend URL)

Success Response (200 OK):

{
  "status": "PASSWORD_RESET_SENT",
  "detail": "If an account with that email exists, a password reset link has been sent."
}

Error Responses:

Status CodeErrorDescription
400Email is requiredMissing email field
500Failed to send password reset emailEmail service unavailable

cURL Example:

curl -X POST https://app.uptimehunt.io/api/v1/auth/password-reset/request \
  -H "Content-Type: application/json" \
  -d '{
    "email": "user@example.com",
    "frontend_url": "https://app.uptimehunt.io"
  }'

Security Design

The response is intentionally vague to prevent email enumeration attacks. The same response is returned whether the email exists or not.


Confirm Password Reset

Complete the password reset process using the token from email.

Endpoint: POST /v1/auth/password-reset/confirm

Authentication: Not required

Request Headers:

Content-Type: application/json

Request Body:

{
  "uid": "MQ",
  "token": "c5h7ke-8f9a2b3c4d5e6f7g8h9i0j1k2",
  "new_password": "newsecurepassword456"
}

Request Schema:

FieldTypeRequiredDescription
uidstringYesBase64-encoded user ID from reset email
tokenstringYesPassword reset token from email
new_passwordstringYesNew password (minimum 8 characters)

Success Response (200 OK):

{
  "status": "PASSWORD_RESET_CONFIRMED",
  "detail": "Password has been reset successfully"
}

Error Responses:

Status CodeErrorDescription
400UID, token, and new password are requiredMissing required fields
400Invalid reset linkInvalid or malformed UID/token
400Invalid or expired reset linkToken has expired or already used
400Password must be at least 8 characters longPassword too short

cURL Example:

curl -X POST https://app.uptimehunt.io/api/v1/auth/password-reset/confirm \
  -H "Content-Type: application/json" \
  -d '{
    "uid": "MQ",
    "token": "c5h7ke-8f9a2b3c4d5e6f7g8h9i0j1k2",
    "new_password": "newsecurepassword456"
  }'

Confirmation Email

A confirmation email is sent to the user after successfully resetting their password.

Enforced SSO

Password-reset requests for accounts that belong to an organization with enforced SSO are refused with a 403 SSO_REQUIRED payload — those accounts have no usable password by policy.


Single Sign-On (SSO)

Organizations can connect their own identity provider (OIDC, SAML 2.0, Google Workspace, GitHub) — see the SSO guides for setup and semantics. These endpoints support SSO and social login flows in custom clients.

SSO Route Lookup

Discover every sign-in route available for an email address. Because email is a display attribute and not an identity key, one email may map to multiple separate accounts — at most one password account, plus one per social provider, plus one per organization identity provider (identity realms). Login UIs should be email-first: call this endpoint, then render the matching password prompt, social buttons, and/or an account chooser.

Endpoint: POST /v1/auth/sso/lookup

Authentication: Not required (rate-limited)

Request Body — send exactly one of the two keys:

{
  "email": "alex@example.com"
}

or, for direct organization entry (the login page's Use single sign-on option):

{
  "org_slug": "acme"
}

Success Response (200 OK):

{
  "password": true,
  "idps": [
    {
      "org_slug": "acme",
      "label": "Acme Corporation",
      "redirect_url": "https://auth.uptimehunt.io/accounts/saml/acme/login/?process=login",
      "enforced": false
    },
    {
      "org_slug": "umbrella-corp",
      "label": "Umbrella Corp (Google Workspace)",
      "redirect_url": "https://auth.uptimehunt.io/accounts/oidc/umbrella-corp/login/?process=login",
      "enforced": true
    }
  ],
  "social": []
}

All three keys are always present.

FieldDescription
passwordWhether password sign-in is available for this email — show the password field. false when no password account exists or the account's organization enforces SSO (its password route is withheld from the chooser)
idpsSSO routes for this email: organizations whose identity provider covers the email's verified domain, where an SSO account with this email already exists, or where the email's password account is a member of an organization with enforced SSO (membership grants the route even without a verified domain). Empty when none
idps[].enforcedtrue when that organization enforces SSO for this account. Clients should auto-redirect only when exactly one route exists, it is enforced, and password is false — every other combination renders explicit choices, never a surprise redirect
socialPlatform social sign-in routes ({provider, label, redirect_url} each). Populated only when the email's domain is a known public mail provider (gmail.com, outlook.com, …) and the platform has social providers configured; company-looking domains always get []. Derived from the domain alone — never from account lookups — so identical for known and unknown addresses on the same domain

The response shape is constant — the same structure is returned whether or not any routes exist, and the endpoint is rate-limited to make account enumeration impractical. The configured social providers can also be discovered without an email via GET /v1/auth/social-providers — the same provider set under that endpoint's own field names (its idprovider here, login_urlredirect_url).

Lookup by organization slug: the org_slug form returns that organization's SSO route (password is always false on this path, and so is idps[].enforced — without an email there is no account to evaluate enforcement against; social is always []). It works for any enabled identity provider regardless of domain verification — this is how members reach IdPs that are never email-routed for their first sign-in, and what powers the organization sign-in link …/login?org={slug}. An unknown slug, an organization without an identity provider, and one whose provider is disabled all return the identical {"password": false, "idps": [], "social": []}, so the endpoint does not reveal whether an organization exists. When both keys are sent, email takes precedence.

cURL Example:

curl -X POST https://app.uptimehunt.io/api/v1/auth/sso/lookup \
  -H "Content-Type: application/json" \
  -d '{"email": "alex@example.com"}'

curl -X POST https://app.uptimehunt.io/api/v1/auth/sso/lookup \
  -H "Content-Type: application/json" \
  -d '{"org_slug": "acme"}'

Browser Flow and Token Exchange

A full SSO sign-in works like this:

  1. Client calls lookup (above) and the user picks an SSO route.
  2. Browser navigates to the route's redirect_url — the UptimeHunt authentication host (auth.uptimehunt.io) redirects to the organization's IdP.
  3. After IdP authentication, the browser lands back on the application at /auth/sso/callback?code={handle}, where code is a one-time, single-use handle valid for 60 seconds.
  4. The client exchanges the handle for the standard JWT pair:

Endpoint: POST /v1/auth/sso/exchange

Request Body:

{
  "code": "{handle}"
}

Success Response (200 OK): the same token pair as login (access_token, refresh_token, token_type, expires_in, user).

Error Responses:

Status CodeErrorDescription
401Invalid or expired sign-in codeThe handle is malformed, older than 60 seconds, or has already been used

From here on, SSO-authenticated clients use the exact same access/refresh token mechanics as password-authenticated ones. Platform social sign-ins ride the exact same handoff: the browser lands on /auth/sso/callback?code={handle} and the client exchanges the handle identically.

Social Sign-In Providers

Discover the platform's configured social sign-in providers — the email-independent companion to the lookup's social[] key. The provider set is the same, but the item field names are this endpoint's own: id carries what the lookup returns as provider, and login_url what it returns as redirect_url (label is shared). The two shapes are both intentional — don't assume the items are interchangeable.

Endpoint: GET /v1/auth/social-providers

Authentication: Not required

Success Response (200 OK):

{
  "providers": [
    {"id": "google", "label": "Google", "login_url": "https://auth.uptimehunt.io/accounts/google/login/?process=login"},
    {"id": "github", "label": "GitHub", "login_url": "https://auth.uptimehunt.io/accounts/github/login/?process=login"}
  ]
}

providers is empty when no social provider is configured on the deployment — clients then render no social buttons at all.

GitHub Organization Access

Accounts signed in with the platform GitHub provider can discover and join organizations mapped to their GitHub organizations — see SSO with GitHub for the full model and error codes:

EndpointPurpose
GET /v1/auth/github/orgsMapped organizations with join state (member / eligible)
POST /v1/auth/github/orgs/{slug}/joinExplicitly join a mapped organization (verified against GitHub at that moment)

Service Account Tokens for Automation

Machine clients — Terraform, Ansible, the Kubernetes auto-discovery operator, CI pipelines — should authenticate with a service account token (also called an organization token) rather than a personal API token:

  • Org-pinned: the token is bound to exactly one organization at creation. No X-Org-Slug header needed; the active org is always the bound org.
  • Scoped: capabilities are capped at the automation-safe read-only or automation presets. The token can never gain SSO, member-management, billing, or token-oversight access.
  • Durable: the token survives staff changes — it is attached to the organization, not to a person.

Mint a service token at Settings → Tokens → Service tokens (UI) or POST /api/v1/orgs/{slug}/tokens (API). See Service Account / Organization Tokens for the full guide, including preset definitions and consumer-specific examples (Kubernetes, Terraform, Ansible).

Organization Context (X-Org-Slug)

All resource endpoints (/services, /projects, /integrations, /alert-rules, /incidents, …) operate on the request's active organization. The optional X-Org-Slug header selects it:

curl https://app.uptimehunt.io/api/v1/services \
  -H "Authorization: Bearer {access_token}" \
  -H "X-Org-Slug: acme"

Resolution order when determining the active organization:

  1. X-Org-Slug header — must name an organization the authenticated user is a member of.
  2. The API token's organization bindingservice account tokens are bound to an organization at creation; requests authenticated with such a token resolve to that org automatically. Sending X-Org-Slug to a different org returns 403. This lets header-less machine clients (Terraform, Ansible, the Kubernetes operator) target a team organization without any header.
  3. The user's personal workspace, if they have one.
  4. The user's sole organization, if they belong to exactly one.
  5. Otherwise the request is rejected with 403 {"code": "ORG_CONTEXT_REQUIRED"} — a multi-organization account must say which organization it means.

Organization-only accounts have no personal workspace

Not every account has a personal workspace: team-founder sign-ups, invitation sign-ups, and SSO-provisioned (JIT) members are organization-only accounts. For them step 3 never applies — an account with a single membership resolves through step 4 (its sole organization), and an organization-only account that belongs to several organizations must send X-Org-Slug (or authenticate with an organization-bound API token), otherwise the request fails with ORG_CONTEXT_REQUIRED. The web app always sends the header, so this only concerns custom API clients.

Properties:

  • Backward compatible: a single-workspace user sending no header gets exactly the pre-organizations behavior.
  • An X-Org-Slug naming an unknown organization or one you are not a member of returns 404 in both cases — the API does not reveal which organizations exist.
  • Role capabilities are evaluated against the active organization on every request — see Roles & Permissions.
  • Organization-management endpoints live under /v1/orgs/{slug}/... and take the slug from the path instead.

Structured Permission Errors

Authorization failures return machine-readable bodies so clients and IaC tooling can produce actionable diagnostics.

Capability denied (403):

{
  "error": "permission_denied",
  "required": "service.edit",
  "org": "acme"
}

required is the missing capability; org is the active organization the check ran against.

Other structured 403 codes:

CodeMeaning
SSO_REQUIREDPassword authentication refused — the account's organization enforces SSO; follow redirect_url, or fall back to the org_slug field when it is null (full payload)
TRIAL_EXPIREDWrite rejected — the active organization's trial has expired; reads still work
ORG_CONTEXT_REQUIREDNo organization context could be resolved — send X-Org-Slug

Using Authentication Tokens

Token Storage

Store tokens securely in your application:

  • Web Applications: Use secure HTTP-only cookies or browser localStorage
  • Mobile Applications: Use secure storage mechanisms (Keychain, KeyStore)
  • Server-to-Server: Use environment variables or secure credential storage

Making Authenticated Requests

Include the access token in the Authorization header for all authenticated API requests:

Authorization: Bearer {access_token}

Example Request:

curl -X GET https://app.uptimehunt.io/api/v1/services \
  -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."

Handling Token Expiration

Access tokens expire after 15 minutes. Implement token refresh logic:

// JavaScript/TypeScript example
async function apiRequest(url, options = {}) {
  let accessToken = localStorage.getItem('access_token');

  // Add authorization header
  options.headers = {
    ...options.headers,
    'Authorization': `Bearer ${accessToken}`
  };

  let response = await fetch(url, options);

  // If token expired, refresh and retry
  if (response.status === 401) {
    const refreshToken = localStorage.getItem('refresh_token');

    // Refresh the token
    const refreshResponse = await fetch('/api/v1/auth/refresh', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ refresh_token: refreshToken })
    });

    if (refreshResponse.ok) {
      const { access_token } = await refreshResponse.json();
      localStorage.setItem('access_token', access_token);

      // Retry original request with new token
      options.headers['Authorization'] = `Bearer ${access_token}`;
      response = await fetch(url, options);
    } else {
      // Refresh failed, redirect to login
      window.location.href = '/login';
    }
  }

  return response;
}
# Python example using requests
import requests
from datetime import datetime, timedelta

class APIClient:
    def __init__(self, base_url):
        self.base_url = base_url
        self.access_token = None
        self.refresh_token = None
        self.token_expiry = None

    def login(self, email, password):
        response = requests.post(
            f"{self.base_url}/v1/auth/login",
            json={"email": email, "password": password}
        )
        response.raise_for_status()
        data = response.json()

        self.access_token = data['access_token']
        self.refresh_token = data['refresh_token']
        # Token expires in 900 seconds (15 minutes)
        self.token_expiry = datetime.now() + timedelta(seconds=data['expires_in'])

        return data['user']

    def refresh_access_token(self):
        response = requests.post(
            f"{self.base_url}/v1/auth/refresh",
            json={"refresh_token": self.refresh_token}
        )
        response.raise_for_status()
        data = response.json()

        self.access_token = data['access_token']
        self.token_expiry = datetime.now() + timedelta(seconds=data['expires_in'])

    def request(self, method, endpoint, **kwargs):
        # Refresh token if expired or about to expire
        if self.token_expiry and datetime.now() >= self.token_expiry - timedelta(minutes=1):
            self.refresh_access_token()

        headers = kwargs.pop('headers', {})
        headers['Authorization'] = f"Bearer {self.access_token}"

        response = requests.request(
            method,
            f"{self.base_url}{endpoint}",
            headers=headers,
            **kwargs
        )

        # Handle token expiration
        if response.status_code == 401:
            self.refresh_access_token()
            headers['Authorization'] = f"Bearer {self.access_token}"
            response = requests.request(
                method,
                f"{self.base_url}{endpoint}",
                headers=headers,
                **kwargs
            )

        return response

# Usage
client = APIClient("https://app.uptimehunt.io/api")
client.login("user@example.com", "password123")
response = client.request("GET", "/v1/services")
services = response.json()

Security Best Practices

Token Security

  1. Secure Storage: Never store tokens in plain text or client-side code
  2. HTTPS Only: Always use HTTPS in production to prevent token interception
  3. Token Rotation: Implement regular token refresh to minimize security risks
  4. Logout: Invalidate tokens on logout by removing them from storage

Password Security

  1. Minimum Length: Enforce minimum 8-character passwords
  2. Complexity: Use passwords with mixed character types
  3. Unique Passwords: Never reuse passwords across services
  4. Password Managers: Recommend using password managers

API Security

  1. Input Validation: Validate all input data on the client and server
  2. Error Handling: Don't expose sensitive information in error messages
  3. Monitoring: Monitor authentication attempts for suspicious activity

Common Authentication Errors

401 Unauthorized

Cause: Missing, invalid, or expired authentication token

Solutions:

  • Verify token is included in Authorization header
  • Check token format: Bearer {token}
  • Refresh expired access token using refresh token
  • Re-authenticate if refresh token is expired

400 Bad Request

Cause: Invalid request parameters or validation errors

Solutions:

  • Verify all required fields are provided
  • Check field formats (email, password length)
  • Review error response for specific validation messages

403 Forbidden

Cause: Valid authentication but insufficient permissions

Solutions:

  • Inspect the structured error body — it names the missing capability or policy code (SSO_REQUIRED, TRIAL_EXPIRED, ORG_CONTEXT_REQUIRED)
  • Verify your role in the active organization carries the required capability
  • Check the organization context the request resolved to
  • Contact an organization Owner for access

JWT Token Structure

UptimeHunt uses JWT tokens with the following configuration:

SettingValueDescription
AlgorithmHS256HMAC with SHA-256
Access Token Lifetime15 minutesValidity period for access tokens
Refresh Token Lifetime7 daysValidity period for refresh tokens
Header TypeBearerAuthorization header type
User ID Claimuser_idClaim containing user identifier

Token Payload Example

{
  "user_id": 1,
  "token_type": "access",
  "exp": 1705318200,
  "iat": 1705317300,
  "jti": "abc123def456"
}
ClaimDescription
user_idUnique identifier for the authenticated user
token_typeToken type (access or refresh)
expExpiration timestamp (Unix time)
iatIssued at timestamp (Unix time)
jtiJWT ID (unique token identifier)

On this page