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:
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:
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:
curl 'https://api2.host-tracker.com/account' \
-H 'Authorization: Bearer YOUR_TOKEN'
{
"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:
HTTP/2 200
HTTP/2 422
{ "code": "validation_failed", // rejected for an EMPTY body, not for a missing scope -
// the scope check already let it through. }
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:
{
"code": "missing_scope",
"status": 403,
"errors": [ { "required": "monitor:write", "granted": ["monitor:read"] } ]
}
{
"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.
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.
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:
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" } ]
}
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.
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.
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]" } ]
}'
{
"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.
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.
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.
curl 'https://api2.host-tracker.com/agent/ip'
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:
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:
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:
{ /* … */ "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.
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.
What actually limits the damage of a leak:
- Mint narrowly. A token scoped to
monitor:readalone 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.
| Field | Wire shape | Verified |
|---|---|---|
| tokenExpiration | Unix seconds, UTC | Minted for now + 120s; the token's exp claim matched exactly. |
| tokenDuration | a .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:
HTTP/2 400
{ "error": "ValidationError", "message": "tokenExpiration must resolve to a UTC instant in the future; the requested value has already passed." }
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:
{
"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.
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-Keyis required. - Webhooks - signature verification, retries, secret rotation.
- The interactive reference - every endpoint, its required scope, and its exact request/response shapes.