Skip to main content

No matching sections.

Authentication

The quickstart gets a token minted and a first call made. This page is what happens next: the full scope model and its one genuinely surprising rule, what every kind of refusal actually looks like, and a plain answer to the question the quickstart doesn't ask - can a leaked token be shut off?

How auth works

Every request carries one header:

every v2 request
Authorization: Bearer YOUR_TOKEN

There is no OAuth flow, no client id/secret pair and no registration step. This is the whole security scheme, as the OpenAPI document itself describes it:

bearerToken (http, scheme bearer, format JWT) - "A bearer token, minted on your profile page. Send it as Authorization: Bearer <token>. A token carries the scopes you selected when minting it and, optionally, an IP allow-list. There is no authorization flow to complete and no client registration - mint a token, store it, use it."

The token identifies both who is calling and what they may do - there is no separate account id anywhere in the URL or the body. A read of your own account, with nothing else in the request but the header:

request
curl 'https://api2.host-tracker.com/account' \
  -H 'Authorization: Bearer YOUR_TOKEN'
200 OK (abridged)
{
  "id": "ff96977f-…",
  "login": "claudeaudit0727",
  "timezone": "Etc/UTC",
  "flags": { "enabled": true, "active": true, "overlimited": false },
  "package": { "name": "30-Days Free Trial", /* … */ },
  "usage": { "monitor": { "used": 14, "allowed": 100 }, /* … */ },
  "limits": { "intervals": [60], "maxBulkItems": 500, /* … */ }
}

No token at all answers 401 invalid_token - see below for the exact shape. There is no per-user API host or path prefix to get wrong: every token, for every account, talks to the same https://api2.host-tracker.com.

The scope model

Twelve leaf scopes, one per action on a domain:

monitor:readmonitor:writecontact:readcontact:writesubs:readwebhook:readwebhook:writestatuspage:readstatuspage:writeaccount:readaccount:writecheck:readcheck:write

and seven family scopes, one per domain, the bare domain name:

monitorcontactsubswebhookstatuspageaccountcheck

A family satisfies every leaf under it: a token minted with just monitor passes a check for both monitor:read and monitor:write. Proved live - the same family-scoped token against both actions, with nothing else granted:

token: scopes=["monitor"]  ·  GET /monitor?limit=1
HTTP/2 200
same token  ·  POST /monitor {}
HTTP/2 422
{ "code": "validation_failed", // rejected for an EMPTY body, not for a missing scope -
  // the scope check already let it through. }
The rule that will surprise you: leaves do not imply each other, even within the same domain. monitor:write does not satisfy monitor:read, and the reverse is equally false. Holding one buys you nothing on the other. Grant both leaves - or use the family - if your integration does both.

This is deliberate, not an oversight: a write-only integration (a provisioning script that creates monitors and never lists them back) and a read-only one (a status dashboard) are both legitimate minimal-privilege shapes, and they are different privileges. Folding write into read - or read into write - would force one of those integrations to carry access it never uses. Proved live, both directions, from the same account:

token: scopes=["monitor:read"]  ·  POST /monitor
{
  "code": "missing_scope",
  "status": 403,
  "errors": [ { "required": "monitor:write", "granted": ["monitor:read"] } ]
}
token: scopes=["monitor:write"]  ·  GET /monitor?limit=1
{
  "code": "missing_scope",
  "status": 403,
  "errors": [ { "required": "monitor:read", "granted": ["monitor:write"] } ]
}

The reverse of the umbrella rule is also worth stating precisely, since it is easy to assume the opposite: no endpoint requires a bare family scope, so this asymmetry never bites you from the other side. Every index row requires a leaf, and a leaf is satisfied by its own family or by itself - never by a sibling leaf.

The 19-scope catalogue is queryable, not just documented here. GET /account/quota returns every mintable scope with a one-line description in its scopes[] array - the same 13 leaves and 7 families above, worded from the source registry. Read it rather than hardcoding this list if you are building a token-scope picker.
Two spellings you may have seen are gone, with no aliases. The instant-check family is check (check:read / check:write) - it used to be ic, and now reads as the /check resource it gates. And subs:write no longer exists: it gated no operation, because every subscription write rides its parent's monitor:write or contact:write. ic, ic:read, ic:write and subs:write are refused by the mint and satisfy nothing if presented. subs:read stays - it is what the two flat account-wide subscription lists (GET /alert, GET /report) require.

What a refusal looks like

Two distinct failures, and they are not interchangeable - one means "you have no valid credential", the other means "your credential is valid but too narrow".

No credential, or a bad one - 401 invalid_token

Both are the same code with a different reason, and a different www-authenticate header - useful if you inspect headers instead of parsing the body:

no Authorization header at all
HTTP/2 401
www-authenticate: Bearer

{
  "code": "invalid_token",
  "status": 401,
  "detail": "This endpoint requires an access token (Authorization: Bearer …) or a signed-in session.",
  "errors": [ { "reason": "missing" } ]
}
a malformed / garbage token
HTTP/2 401
www-authenticate: Bearer error="invalid_token"

{
  "code": "invalid_token",
  "status": 401,
  "detail": "The presented credentials were rejected.",
  "errors": [ { "reason": "invalid" } ]
}

An expired token answers the same way - the signature and the claims are checked together, and the API has no way to tell you "it was valid once" versus "it was never valid" without also telling an attacker which tokens ever existed. Both dead ends look identical on purpose.

A valid credential, missing a scope - 403 missing_scope

Always carries both sides of the mismatch, so a client can retry with exactly the right mint rather than guessing: required (a single scope string) and granted[] (what the token actually holds - present as an array even when it holds nothing at all). See the two examples in the scope-model section above; the shape does not change endpoint to endpoint.

Both codes, and every other v2 failure, are the same RFC 9457 problem document. See Errors for the general anatomy - code, errors[], type - and the three-family branching model.

Compound writes, compound scopes

A single request can create more than one kind of resource. Creating a monitor with inline contacts is the common case - POST /monitor with a contacts[] array creates the contacts and the monitor in one call. That still costs both domains' write scopes: monitor:write does not let you reach into the contact domain for free.

request  ·  token: scopes=["monitor:write"]
curl -X POST 'https://api2.host-tracker.com/monitor' \
  -H 'Authorization: Bearer YOUR_TOKEN' \
  -H 'Content-Type: application/json' \
  -d '{
    "type": "http",
    "url": "https://example.com",
    "contacts": [ { "ref": "c1", "type": "email", "address": "[email protected]" } ]
  }'
403 missing_scope
{
  "code": "missing_scope",
  "status": 403,
  "errors": [ { "required": "contact:write", "granted": ["monitor:write"] } ]
}

The same call with both monitor:write and contact:write granted succeeds normally. This is the general shape, not a monitor-specific special case: whenever one write reaches into a second domain, both domains' write scopes are required - a compound endpoint is never a side door around the scope check on the resource it also happens to touch.

Note the inline contact's own required shape while you're here: ref (your own label, referenced by subscription blocks that don't have a real contact id yet), type and address are all required - a missing ref answers 422 validation_failed, checked before the scope itself, so a malformed body and a missing scope never get confused for each other.

Anonymous endpoints

GET /agent/ip - the list of addresses Host-Tracker's check agents call out from - takes no token, on purpose. It belongs to a small anonymous reference tier: read-only, identical-for-every-caller catalogue data that needs no account context to answer - GET /agent, GET /agent/pool, GET /agent/ip, GET /monitor/type (and its /{type} and /schema reads), GET /contact/type, GET /report/type, GET /check/type and GET /alert/type. That is ten endpoints, and eighteen published operations once the eight paged ones' body-query twins (POST <path>/q) are counted - they are anonymous too. Every other v2 API operation requires a token.

The tier's meter is per client address, and it stays per address even when you send a token. A bearer token on one of these reads is not ignored - GET /monitor/type and GET /agent/pool read it to add their account-scoped block - but it does not move the call onto your account's quota. Rate-limiting here is by source address for everybody, and the whole tier shares one bucket (separate from GET /agent/ip's own). If you fan out catalogue reads from a single egress address, that address is what gets throttled, not the account.

Called with a token, GET /monitor/type is auth-aware: each row gains an extra accountLimits { minInterval, available } block scoped to your package, on top of the same global catalogue an anonymous caller sees.

The reason is ordering, not laxity: the typical consumer of /agent/ip is a firewall allow-list script, and that script often runs before any credential exists on the machine it's provisioning - a fresh box being built from a base image, a CI job standing up a sandboxed target. Requiring a bearer token here would mean putting a long-lived account secret into a firewall config that has no business holding one - worse security, not better, for a read that reveals nothing about any account. The same reasoning covers the rest of the tier: type catalogues and the agent fleet are the same for every caller, so gating them behind a token would buy nothing but friction.

request - no Authorization header
curl 'https://api2.host-tracker.com/agent/ip'
200 OK - response headers
HTTP/2 200
ratelimit-limit: 60
ratelimit-remaining: 59
ratelimit-reset: 300

Anonymous does not mean unmetered: this endpoint carries its own per-client-IP rate bucket, entirely separate from the account-scope quota covered below - there is no account to charge, so the API protects itself the only way it can, by address. Driven past its limit for real:

429 rate_limited - after 60 requests in the window
HTTP/2 429
retry-after: 89
ratelimit-remaining: 0

{
  "code": "rate_limited",
  "status": 429,
  "detail": "This endpoint is anonymous and rate-limited per client address.",
  "errors": [ { "limit": 60, "window": 89, "retryAfter": 89 } ]
}

Hardening a token

Two knobs shrink what a token can do beyond its scopes - worth using on anything that leaves your own hands (a CI secret store, a script handed to a contractor, an agent that only needs to act from one place).

IP allow-list

The mint accepts up to ten entries - exact addresses or from-to ranges - and the profile page's token form has a field for it today. A call from any other address is refused before your scope is even checked:

token minted with an allow-list that excludes the caller
HTTP/2 403
{
  "code": "ip_not_allowed",
  "status": 403,
  "detail": "This token may only be used from the addresses configured on it.",
  "errors": [ { "clientIp": "::1" } ]
}

Note what the error does not reveal: your caller's own address is echoed back (handy for debugging a typo'd entry), but the allow-list itself is not. An attacker holding a stolen token still cannot learn which addresses would let them in.

Self-cap

The mint also accepts an optional positive integer cap - the most calls this one token may spend out of the account's quota window, independent of every other token on the account. GET /account/quota echoes it back as tokenCap when the presented token carries one, so a caller can always confirm what it minted with:

GET /account/quota with a capped token
{ /* … */ "tokenCap": 1 /* echoes the mint's cap, verbatim */ }

The point is a storage-free blast radius control: hand an automation a token that is deliberately weaker than your account, without minting a second, smaller-quota account for it. Per the enforcement source (V2QuotaGate), the effective allowance becomes min(accountQuota, cap) - that half is read from code, not driven to an actual 429 in this pass, since this account currently has no configured quota window to cap against (see the honest note in the next section). The round-trip above - mint with a cap, read it back - is live.

The profile page does not have a field for this yet. The mint API accepts cap; the token form's UI today exposes name, scopes, expiration and the IP allow-list, but not a cap input. If you need one before that ships, you can mint from a browser console against your own signed-in session - the same endpoint the token form itself calls.

Revocation, honestly

Tokens are not stored on our side. The whole grant - your account, the scopes, the IP allow-list, the cap, the expiry - travels inside the token's own signed claims, and a bearer token is valid whenever its signature checks out and its claims haven't expired. There is no server-side table of issued tokens to delete a row from, and confirmed from the minting source itself: nothing about the token is tied to your password, your session, or anything else that changing later would invalidate it.

The direct consequence: a leaked token cannot be revoked before it expires. Changing your password does not stop it. Signing out everywhere does not stop it. There is no "revoke" button, because there is nothing server-side to flip. This is a deliberate trade - custody of the credential is the customer's, and the API asks nothing of its own storage to validate one - and it means the mitigations below are not optional hygiene, they are the only lever you have.

What actually limits the damage of a leak:

  • Mint narrowly. A token scoped to monitor:read alone leaks a read of your monitor list, not write access to anything.
  • Set an expiry. Left alone a token lasts ten years - fine for a token that lives in a secret manager, wrong for anything you hand to a person or a short-lived job. A capability that has to expire soon is a capability that self-heals.
  • Use the IP allow-list. A token pinned to your CI runner's egress address is useless from anywhere else, leaked or not.
  • Use the self-cap. Bounds how much damage even a fully-scoped, unpinned token can do before its own allowance runs out.

One thing does stop every token on an account at once, but it is blunt and it is not self-service: per the mint and quota-gate source, an account whose API access has been disabled account-wide (a billing/support action, not a per-token one) refuses new mints and - on the metered surface, by the same account-level check - existing tokens as well. That is a kill switch for the whole account, not a way to revoke one token while leaving the rest working, and it is not something this guide can walk you through triggering.

Expiry

The mint takes an absolute expiry, a relative duration, or neither.

FieldWire shapeVerified
tokenExpirationUnix seconds, UTCMinted for now + 120s; the token's exp claim matched exactly.
tokenDurationa .NET TimeSpan string, e.g. "01:00:00" (hours) or "7.00:00:00" (days) Minted with "01:00:00"; exp − nbf was exactly 3600 seconds.
Neither-Ten years. Minted with no expiry field at all; the token's own lifetime was ~315,619,200 seconds - a hair over ten years, matching the mint's documented default exactly.

A tokenExpiration that has already passed is refused outright rather than minting a token that is dead on arrival:

tokenExpiration in the past
HTTP/2 400
{ "error": "ValidationError", "message": "tokenExpiration must resolve to a UTC instant in the future; the requested value has already passed." }
That shape - {error, message}, not a problem document - is the one place on this page that looks different from everything else in the v2 catalogue. Minting happens through the profile page's own endpoint, which predates the v2 API and its RFC 9457 shape; it is not itself a v2 endpoint. Everything the resulting token then does against the v2 API uses the problem-document shape throughout.

The profile page's own duration presets, if you'd rather not compute a Unix timestamp: 1 day, 7 days, 30 days, 120 days, 1 year, a custom date, or the ten-year default.

Rate limits and quota

GET /account/quota reports your headroom per scope, on demand, without spending any of it - the pool names are check (instant checks) and account (everything else), each holding zero or more configured quota windows:

GET /account/quota - a real response
{
  "pools": {
    "check": { "quotas": [] },
    "account": { "quotas": [] }
  },
  "scopes": [ /* the 19-entry catalogue from the scope-model section */ ]
}

Both arrays are empty on this account because no quota window is configured for it in this environment - not a bug, just what "nothing bound yet" looks like on the wire.

Be told what actually happens, not just what the design intends: on this account, no v2 API call carried a RateLimit-* header - checked on both an unmetered-looking call and a genuinely metered one (GET /account and GET /monitor, both scope-gated, both metered by the source). The headers are published only when a request resolves to a bound quota window (per V2QuotaGate); an account with no configured window for the scope it just spent gets a plain response with no such headers at all, metered or not. If your own account shows the same empty quotas: [] on GET /account/quota, expect the same: no RateLimit-* headers on ordinary calls, and no 429 quota_exceeded either, until a window is configured for your plan.

The one place headers did show up, reliably, every call: the anonymous GET /agent/ip covered above - it carries its own always-on per-IP bucket, unrelated to account quota, and it was straightforward to drive to a real 429 (see above). That is the honest state of both mechanisms as tested live against this environment - the account-scope quota exists, is queryable, and is wired to publish headers the moment a window is configured for it; the anonymous endpoint's bucket is live today, unconditionally.

The two 429 codes remain distinct for the reason Errors explains in full: quota_exceeded means your account's allowance for the window is spent (wait for resetAt, or upgrade); rate_limited - the one this guide actually produced, on the anonymous endpoint - means a short-window throttle on that one endpoint, unrelated to any quota, and clears itself in seconds.

Where to go next

  • Quickstart - mint your first token and make your first five calls.
  • Errors - the full problem-document shape and the three families of fix.
  • Jobs & idempotency - the async job contract and where Idempotency-Key is required.
  • Webhooks - signature verification, retries, secret rotation.
  • The interactive reference - every endpoint, its required scope, and its exact request/response shapes.