Every endpoint resolves its caller through the same code path. There is one credential scheme, one set of headers, and one lookup. What authenticates one endpoint authenticates all of them, and the only thing that varies between endpoints is which grants the key has to hold.
A request is asked three questions, in this order. Each has its own failure code, and keeping them separate is what makes a refusal actionable.
| Question | Answered by | Failure |
|---|---|---|
| Who are you? | A live, unrevoked, unexpired key matching the presented secret | 401 Unauthorized |
| What may you do? | The key's scopes | 403 Forbidden |
| Whose data is this? | The key's federation | 403 Forbidden |
The credential
A credential is a pair.
- Client ID. The identifier of the key. Not a secret, safe to log, safe to put in a config file.
- Client Secret. 32 bytes of cryptographic randomness, hex encoded, so 64 hexadecimal characters. This is the whole of the proof.
The server stores a SHA-256 hash of the secret and the last eight characters, kept only so a person can recognise a key in a list. The plaintext is never written anywhere. It exists once, at the moment the key is created, and after that it exists only where you put it.
Anyone holding the secret is the key, with all of its scopes and all of its federation, until it is revoked or expires. Keep it in a secrets manager or an environment variable, never in source control, never in a URL, and never in a query string. If one is exposed, rotate it rather than hoping.
The header forms
Send the id and the secret as separate headers. This is the form every Fetchit client uses.
Client-ID: cm1f8q0a70000abcd1234wxyz
Client-Secret: 9f2c41ab...0e7d3b8aEach half is looked up independently, so four spellings are accepted for each, checked in this order and first non-empty wins.
| Half | Accepted headers, most specific first |
|---|---|
| Id | X-Fetchit-Client-ID, Fetchit-Client-ID, X-Client-ID, Client-ID |
| Secret | X-Fetchit-Client-Secret, Fetchit-Client-Secret, X-Client-Secret, Client-Secret |
The two halves resolve separately, so spelling one X-Fetchit-Client-ID and the other Client-Secret works. Header names are case insensitive.
For a client whose Authorization header is already spoken for, or one that would rather carry a single value, the secret alone is enough:
Authorization: Bearer 9f2c41ab...0e7d3b8aX-API-Key: 9f2c41ab...0e7d3b8aThe pair is used only when both halves are present and non-empty. If either is missing, the request falls through to the bearer form, and a Client-ID sent without a secret is simply a stray header.
Federation
Federation is what a key is locked to. It is not a permission. It is the answer to "whose data is this", and it is carried on the key rather than in the request body.
That matters because a leaked federated key is scoped to one practice. If the tenant were named in the body instead, any leaked key would reach every practice, and the boundary would sit in the caller's hands rather than the credential's.
Federated keys
A federated key is locked to an organization, and sometimes further, to a machine or a person. Every act it takes lands there, which is why requests never name their clinic: the key is the tenant.
This is what a clinic's own integration holds, and what the phone worker reports calls with. It is the ordinary case, and it is the only kind most endpoints accept.
Non-federated keys
A non-federated key is locked to nothing. It identifies the caller and says nothing about whose data a request touches, so on its own it cannot act on a tenant. Any endpoint that serves one clinic refuses it:
{
"success": false,
"error": "403 Forbidden",
"message": "The provided API key is not federated to an organization."
}Where a non-federated key is the right credential, something else on the request carries the identity, and that something is an Authorization bearer.
The desktop application is the case worth understanding. The app pair bundled into the Electron build is non-federated on purpose: it ships inside a binary, so it is extractable by design, and it identifies the application rather than a tenant or a user. Its job is to keep the public device endpoints from being an open probe surface, and to give the request log an app to attribute traffic to. Holding it grants nothing on its own.
The clinic and the machine come from a second credential presented beside it:
Client-ID: <the bundled app key>
Client-Secret: <the bundled app secret>
Authorization: Bearer <the device credential>The device credential is minted when somebody signs the machine in, and it is federated to the organization, the machine and the login. It arrives in one of two forms, and both authenticate the same underlying key:
- A session token. The ordinary case. Its signature and expiry are checked, and then the key it names is read and confirmed live.
- The raw device bearer. The credential the session is traded from, still accepted for clients that predate sessions.
Either way the key row is checked on every request rather than trusted from the token's claims. A token is a statement about the past, and "is this device still signed in" is a question only the row can answer. So a device signed out from the organization's devices page stops working on its very next call, not whenever its token happens to lapse.
Scopes
What a key may do is a set of catalogue ids, dotted for readability.
org.calls.read
org.calls.events.call_ended
org.crm.config.read
broker.connectThey read as <domain>.<resource>.<act>. org.* covers acts on one organization, its members, roles, keys, devices, CRM and phone configuration, call history and popups. broker.* covers the message broker. admin.* is site operator work and is never held by a tenant key.
Matching is exact
The dots do not imply anything. Holding org.crm does not grant org.crm.config.read, and holding org.calls does not grant org.calls.read. A scope grants itself and nothing else.
This is deliberate. It means adding a new scope to the catalogue never silently widens a key that was minted before it existed.
The one exception: wildcards
A scope may be marked a wildcard in the catalogue, and a wildcard does hold everything beneath it.
held: org.calls.events (marked a wildcard)
wanted: org.calls.events.call_recorded
-> granted, because a wildcard ancestor is heldWildcards exist for the families whose membership is open, where a new child arrives without a schema change: a new call event type is a new child of org.calls.events, and a key granted the umbrella yesterday has to hold it today.
Being a prefix does not make a scope a wildcard. It is a deliberate mark on one family, and nothing acquires it by naming.
Refusals name the fix
A key that authenticates but lacks a grant gets a 403 naming the scope that would fix it.
{
"success": false,
"error": "403 Forbidden",
"message": "The provided API key may not report call_recorded."
}This is why scope is checked after the credential resolves rather than folded into the lookup. Folding it in would answer 401, which reads as "your credential is wrong" and sends an integrator off to rotate a key that was fine.
Where keys come from
Keys for a clinic are created from that organization's manage page, under Programmatic access. Creating one needs the org.keys.create act, which the organization's Admin role holds by default, and the key you get is locked to that organization.
Pick the scopes the integration actually needs. The specific act beats the wildcard unless the family is genuinely open ended for you, and one key per integration means you can revoke a single thing later without stopping everything else.
The dialog that creates the key is the only place the full secret appears. Copy it then. It is not recoverable, and losing it means replacing the key.
What makes a key live
Liveness is enforced in the same query that finds the key. There is no sweeper and no grace window.
- Revocation is immediate. A revoked key stops authenticating on its very next request.
- Expiry is immediate. A key whose
expiresAthas passed does not authenticate. A key without one never expires. - The hash must match. For the pair form, the id must name the row and the hash must match it, both in the same lookup.
Every successful request stamps the key's lastUsedAt, which is how you tell what a key is still doing.
Rotation
There is no rotate-in-place. Rotation is: create the replacement, deploy it, then revoke the old key.
- Create a new key with the same scopes from the organization's manage page.
- Deploy the new secret to everything that calls the API.
- Confirm the old key is idle. A key nothing has used since the deploy is safe to retire.
- Revoke it. The next request carrying it gets a
401.
Rotate on a schedule, and immediately on any exposure: a secret in a commit, a log line, a screenshot, or a laptop that left the building.
Quick reference
| Symptom | Meaning |
|---|---|
401, "invalid or missing" | No credential arrived, wrong secret, revoked, or expired. |
403, names a scope | Authenticated, not permitted. Add the scope. |
403, "not federated to an organization" | A non-federated key on an endpoint that serves one clinic. |