Getting started
The Confirmed Life Safety API is read-oriented and scoped per customer. Everything you can reach
through it is something the person your key is bound to can already see in the Confirmed portal.
1. Get a key
API keys are issued by Confirmed, not self-served. There is no signup endpoint and
no key-creation call in this API — deliberately. A key is bound to a named person and inherits that
person's portal access at request time, so creating one is an access decision about a human being
rather than a form a script can submit.
To get one, ask your Confirmed contact. Internally the steps are:
- An administrator opens Oversight → API Keys in the Confirmed admin.
- They choose the person the key is bound to, tick the scopes it needs, and optionally set an expiry.
-
The key is displayed exactly once, at creation. Confirmed stores only a hash of it
and cannot show it again. A lost key is revoked and reminted, never recovered.
Keys look like cls_live_ followed by 43 URL-safe characters. Treat one as a password:
it is a credential for a real person's real portfolio. If a key is exposed, ask for it to be revoked
immediately — revocation takes effect on the very next request.
2. Find your base URL
Every path in this reference hangs off:
https://confirmedlifesafety.com/wp-json/confirmed/v1
That is the base URL of the install serving this page. If you were given a staging environment, its
base URL is the same path on that host — the paths, scopes and response shapes are identical.
3. Make your first call
GET /ping exists for exactly this moment. It touches no customer data, and answers the
only two questions a first call has: is the credential good, and what does it reach?
curl -X GET 'https://confirmedlifesafety.com/wp-json/confirmed/v1/ping' \
-H 'Authorization: Bearer YOUR_API_KEY'
A 200 tells you the key is live and reports the scopes it carries and how many properties
it can see. A 401 means the credential was not accepted — see
Authentication, and in particular the note about servers that strip the
Authorization header, which is the single most common cause of a first call failing on a
key that is perfectly good.
4. Know the two response shapes
Every successful response is one of exactly two shapes, and never anything else:
-
A collection —
{ "data": [ … ], "meta": { … } }, where meta
carries page, per_page, total and total_pages. -
A single resource —
{ "data": { … } }, with no meta key
at all. Not an empty object: the key is absent.
Every error is the same flat object — { "code", "message", "docs_url" } — with no nested
wrapper and no status field inside the body. See Errors.
A machine-readable contract
This page is generated from GET /openapi.json, which you can fetch yourself without a
credential and feed to Postman, Insomnia or openapi-generator. It is the same document
this reference is rendered from, so the two cannot drift.
Authentication
Send your key as a bearer credential on every request:
Authorization: Bearer cls_live_…
HTTPS is required and is enforced by the API itself rather than left to a redirect. A plain-HTTP
request is rejected outright — see insecure_transport.
What a key can reach
A key is bound to a person and inherits that person's portal access
at request time. This has one consequence worth internalising before you build
anything on it: a key can only ever narrow what its bound user can see, never widen it.
Remove a property from that user in the portal and it disappears from their keys on the very next
call — there is no cache to wait out and no re-issue to perform.
A property outside a key's scope answers 404, never 403. The two are
byte-identical to a property id that does not exist, so the route cannot be walked to learn which
ids are real.
Every authentication failure is a 401
Never a 403 — a 403 would concede that the credential you presented is a
real one. There are six distinct codes, and they exist only so that you can tell a missing header
from a revoked key without opening a support ticket:
missing_credential — no credential arrived. Read the troubleshooting note below before assuming you did not send one.invalid_key — the key matches no issued credential. This also covers a key of the wrong shape.key_revoked — the key exists and is no longer active.key_expired — the key's expiry has passed.key_user_invalid — the person the key is bound to no longer exists, or no longer holds a role permitted to carry a key.key_scope_empty — the key resolves to no properties at all. Most often this means access was removed in the portal, not that the key is broken.
A genuine key that lacks the scope an operation requires is the opposite case and is a
403 insufficient_scope. See Scopes.
If you are certain you sent Authorization: Bearer … and still get
401 missing_credential, the header is almost certainly being removed before it reaches
us — or before it leaves you.
Apache and FastCGI strip the Authorization header by default. Unless
CGIPassAuth On is set (or an equivalent
SetEnvIf Authorization … HTTP_AUTHORIZATION rewrite is in place), PHP never sees it, and
the API cannot distinguish that from a request that genuinely carried no credential. Some proxies,
API gateways and corporate egress filters do the same thing.
The API therefore also accepts the same key in an X-API-Key header:
X-API-Key: cls_live_…
It is not in the machine-readable contract, and it is not the form to reach for first — the bearer
header is what generated clients emit and what we will support indefinitely. But it is fully
supported, it takes the identical key, and it is the fix when you do not control the web server that
is eating your headers. Send one or the other, not both;
Authorization is checked first, and anything that is not a Bearer
credential there is treated as absent so the X-API-Key fallback still gets its turn.
If neither works, call GET /ping with the key from a plain curl on a machine
outside your infrastructure. That isolates the question to "is the key good" in one step.
Scopes
Each key carries an explicit list of scopes, chosen when it is issued. Every operation in the
reference below names the one scope it requires.
read:properties — the property list and property detail, and GET /ping.read:documents — compliance documents, and the signed download URLs minted on document detail.read:compliance — the portfolio-level compliance rollup.read:tasks — Confirmed's open to-do list for your properties.write:uploads — the one write operation, uploading a document to a property.
Matching is exact
There is no prefix behaviour and no wildcard. read:properties does not satisfy
read:documents, and nothing satisfies everything. A key that needs to read two
resources carries both scopes. This is deliberate: a prefix rule turns a future scope named
read:properties:financials into an access grant that every existing key silently
already has.
Scope and access are separate gates
They fail differently, and telling them apart saves an afternoon:
-
Missing scope is a
403 insufficient_scope. The key is genuine and we
are telling you so; it simply was not issued for this operation. Ask for the scope to be added. -
A property the key cannot reach is a
404, from an operation whose scope
you do hold. It is identical to the response for a property that does not exist. Access comes from
the bound user's portal permissions, not from the scope list, so no scope change will fix it —
the user needs the property.
A key that resolves to no properties at all is neither of those: it is a
401 key_scope_empty, because there is nothing for it to authenticate against. That
almost always means the bound user's access was removed in the portal.
Rate limits
Limits are per key, not per IP and not per property:
- 120 requests per minute by default. This one is configurable per key — ask if your integration needs more.
- 5,000 requests per day.
The headers
X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset
ride on any response that got as far as the rate-limit check — which means a 200, a
404 for an out-of-scope property, a 500, and the quota 429
itself. X-RateLimit-Reset is a Unix timestamp.
They are absent from 401 and 403 responses. Those are
decided before the key's counter is touched, so there is no budget to report. Code that reads the
headers unconditionally should tolerate their absence rather than throw.
Two different conditions answer 429
-
Your key's quota. Carries
Retry-After and the
X-RateLimit-* headers. Back off until the reset timestamp. -
A per-IP failed-authentication throttle —
20 failures per
minute — which fires before the credential is even looked up. It carries no rate-limit
headers, because no key has been identified to report a budget for. If you are seeing this one, you
are retrying a credential that is not working; fix the credential rather than the backoff.
Staying under them
-
Use
If-None-Match with the ETag from your last collection response. A
304 still costs a request against your quota, but costs no parsing and no bandwidth. -
Use
updated_since on GET /properties for a nightly delta pull instead of
walking the whole portfolio. -
Raise
per_page rather than making more requests. The ceiling is 200 rows.
Errors
Every error, from every endpoint, is the same flat object:
{
"code": "key_revoked",
"message": "This API key is no longer active.",
"docs_url": "/developers/#errors"
}
There is no nested error wrapper and no status field inside the body — the
status is the HTTP status. code is stable and machine-readable, so switch on it.
message is written for a human and may change, so do not.
Status codes
400 — the request could not be understood. invalid_parameter for an unparseable filter; filters are validated, never silently dropped, because answering a narrowing question with the complete list gives you no way to tell it was ignored.401 — authentication failed. Six codes; see Authentication.403 — insufficient_scope. The key is real and was not issued for this operation.404 — the resource does not exist, or is outside this key's scope. The two are deliberately identical.429 — rate_limited. Two different conditions produce it; see Rate limits.500 — internal_error, or spec_unavailable from the contract endpoint. Never carries internal detail; the detail is in our logs.
insecure_transport is not an operation outcome
insecure_transport is a transport precondition, not a result of anything the operation
did — which is why you will not find it listed against any one endpoint's parameters. The API
requires HTTPS and enforces it itself rather than relying on a redirect being followed, so a request
that arrives over plain HTTP is refused with 400 insecure_transport before it reaches a
handler at all. Any path can return it, including the two that need no credential.
If you see it: your client is calling http://, or something between you and us is
downgrading the connection. Nothing about the key, the scopes or the parameters is implicated, and
changing them will not help.
What to retry
- Retry:
429 after Retry-After, and 500 with backoff. - Do not retry:
400, 401, 403 and 404. Every one of them will produce the identical answer to the identical request, and a retry loop on a 401 will trip the per-IP failed-authentication throttle.
Compliance scoring
Compliance appears in two places: a per-property compliance object carrying
score and risk_band, and the portfolio rollup at
GET /compliance/summary. Both come from the same engine the Confirmed portal uses.
score is a 0–1 fraction. The portal shows the same number as a percentage
score is a 0–1 fraction — 0.8571. The Confirmed portal displays that very
same underlying number as 85.71%. They are one value in two units, not a
disagreement between the API and the dashboard, and the most common integration bug on this field is
multiplying a number that was already a percentage or charting a fraction as one.
risk_band is the lowercased band label — low, moderate,
elevated or severe. It is banded on the 0–100 scale, so
score: 0.86 pairs with risk_band: "low" and not, as the unit mismatch
might suggest, with severe.
When there is no score — the property has nothing to score yet, or the scoring engine is unavailable
— both score and risk_band are null. Never a
substituted number. An invented 100 reads as "fully compliant", which is the most
dangerous possible wrong answer on a life-safety record.
The amber window, as configured right now
A document is expiring_soon when its expiration date falls inside the amber window.
On this install that window is currently
30 days.
The window is configuration, not a constant. Confirmed can change it, and when it
changes, every status, every expiring_soon count and every score moves with
it on the next request. Do not hardcode the number in your integration: read
status from the API, or re-read this page. There is no endpoint that reports the window,
which is precisely why it is stated here.
The formula, and what repaired means
A property's score is (green + repaired) / total, where green is its
in-date documents and total is every document that counts toward compliance.
repaired is the one term with no matching field anywhere in this API, so it needs
saying plainly: a document is repaired when a failed inspection it recorded has since been
cleared by a later repair document that Confirmed matched back to it. It is an internal
document status, set during document processing — not a value of the status field this
API returns, and not something you can filter or request. A repaired document is counted in the
numerator exactly like an in-date one, because the deficiency it recorded has been fixed. Documents
still marked as failed are counted in total and not in the numerator.
Expiring-soon documents earn no credit
Amber counts toward the denominator and not the numerator. Stated as bluntly as it deserves:
a property whose only document is expiring soon scores 0, not 0.5.
This is the Confirmed portal's own behaviour, and this API reproduces it deliberately. Making the API
"correct" where the portal is not would mean your dashboard and ours disagree about the same
building on the same day, which is worse than the quirk — a compliance number that depends on which
screen you read it from is a number nobody can act on.
If you need visibility of documents that are about to lapse, do not infer it from the score: read the
documents.expiring_soon bucket on GET /compliance/summary, or filter
documents by status=expiring_soon. The score answers "how compliant is this building
today"; the buckets answer "what is about to stop being true".
What is and is not counted
- Certificates of insurance are excluded from the score entirely — they are a contractual record, not a life-safety inspection.
- Documents with no expiration date have nothing to be in date about and do not move the score.
- A document with a booked re-inspection is
scheduled and is protected from counting as expired for a short grace period. After it, it reverts to what its expiry says. - Discontinued properties are excluded from the portfolio rollup, and from
total before it is computed.
Changelog and versioning
The API is versioned in its path — confirmed/v1. The version in the machine-readable
contract (info.version) moves with the document; the path does not move without a
deliberate, announced break.
What counts as a breaking change
These will never happen inside v1: removing a field, renaming a field, changing a
field's type, removing an enum value you might already be switching on, tightening a validation rule
so a request that worked stops working, or changing what a status code means.
These can happen at any time, so build a client that tolerates them: adding a field
to a response, adding an enum value, adding an optional parameter, adding an
endpoint, and changing the wording of an error message. Switch on code,
never on message, and ignore fields you do not recognise rather than rejecting the
response.
Releases
- 1.0.0
-
First public release. Properties, documents with single-use signed downloads, the portfolio
compliance rollup, open tasks, document upload, the credential check at
/ping, and
the machine-readable contract at /openapi.json.
Deprecations, when they come, will be announced to every customer holding a key before anything
changes, and a deprecated field will keep answering for the whole notice period rather than starting
to return null.