The Confirmed API

A read-oriented REST API over the same compliance data your team sees in the portal — properties, documents, scores and tasks. Keys are issued by Confirmed and inherit a named person’s access, so an integration can never see more than the person behind it.

Base URL https://confirmedlifesafety.com/wp-json/confirmed/v1

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:

  1. An administrator opens Oversight → API Keys in the Confirmed admin.
  2. They choose the person the key is bound to, tick the scopes it needs, and optionally set an expiry.
  3. 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.

Troubleshooting: a 401 missing_credential on a key you know is good

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

  1. Your key's quota. Carries Retry-After and the X-RateLimit-* headers. Back off until the reset timestamp.
  2. 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.

Pagination and caching

Every collection takes page (minimum 1) and per_page (1–200, default 50), and returns a meta object alongside data.

Values are clamped, never rejected

per_page=9999 returns 200 rows. per_page=0 returns 1. per_page=abc returns the default. An absent value returns the default. None of these is an error, because a paging bug should degrade a page size rather than break a nightly sync at 3am.

Filters behave the opposite way and are validated, never clamped: an unparseable updated_since is a 400, not an ignored filter. Silently answering a narrowing question with the complete list is the one failure a client cannot detect.

Walking a collection safely

total_pages is a ceiling and is always at least 1, even for an empty collection, so while page <= total_pages terminates on an empty data array rather than looping forever or skipping the last page.

ETags

Collection responses carry an ETag. Send it back as If-None-Match to get a 304 Not Modified with no body. Weak validators and comma-separated lists are both understood. Single-resource responses carry no ETag.

Authenticated responses are sent with Cache-Control: private, no-store, max-age=0 and Vary: Authorization. Do not cache them in a shared cache — they are one customer's data. GET /openapi.json is the single exception and is deliberately public-cacheable.

One limit that is not in the contract: total on /tasks

GET /tasks computes an exact total by fetching your open tasks and counting them, because the underlying store has no count-only query. That fetch is bounded at 2,000 rows.

The consequence, stated plainly: if your key's scope holds more than 2,000 open tasks, total undercounts, and does so without any error. Pagination still works and every page you walk is real; it is the count that stops being trustworthy. It is generous for any realistic portfolio, and it is not in the machine-readable contract because it is an implementation bound rather than part of the interface — but if you are reconciling our task count against your own and finding ours short, this is why. Tell us: the fix is a count query on our side, not a workaround on yours.

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.

Endpoint reference

Properties

The buildings this key can see.

GET /properties

List properties

Requires an API key carrying the scope read:properties.

Every property this key can see, sorted by name. Properties whose service has been discontinued are excluded, and are excluded before total is computed, so total always agrees with the rows you can walk.

Parameters
NameInTypeDescription
pagequeryintegeroptional

1-based page number. Values below 1, and non-numeric values, are clamped rather than rejected.

Default: 1

per_pagequeryintegeroptional

Rows per page. Clamped, never rejected — per_page=9999 returns 200, per_page=0 returns 1, and a non-numeric value returns the default.

Default: 50

updated_sincequerystringoptional

Return only properties modified at or after this instant — for a nightly delta pull. Compare against each row's updated_at. An unparseable value is a 400, not an ignored filter.

Example: 2026-08-17T14:02:11Z

If-None-Matchheaderstringoptional

The ETag from a previous response to this collection. A match returns 304 with no body. Weak validators (W/"…") and comma-separated lists are both understood.

Responses
StatusMeaning
200

A page of properties.

304

The collection is unchanged since the ETag you sent. No body.

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 the client no way to tell. insecure_transport when the request did not arrive over HTTPS.

401

Authentication failed. code is one of the six AuthErrorCode values. Every one is a 401 and never a 403, because a 403 would concede that the presented credential is real.

**No X-RateLimit-* headers**: this is decided before the key's counter is touched.

403

The key is genuine but does not carry the scope this operation requires. Always insufficient_scope.

**No X-RateLimit-* headers**: the scope check runs before the key's counter is touched.

429

Too many requests.

Two conditions answer with this same rate_limited code:

1. The key's quota — 120/minute (configurable per key) or 5,000/day. Carries Retry-After and the X-RateLimit-* headers, and message names which window was exceeded. 2. A per-IP failed-authentication throttle (20 failures/minute), which fires before any credential lookup. It carries no rate-limit headers, because no key has been identified to report a budget for.

500

Something failed on our side. The response never contains a stack trace, a SQL fragment or a filesystem path.

Response schema 200 application/json
  • data array required

    An array of:

    • id integer required

      Stable Confirmed property identifier. Use it as the path parameter everywhere.

      Example: 1042

    • name string required

      The display name, with the "Portfolio Dashboard – " prefix removed and HTML entities decoded, matching what the customer sees in their portal. Falls back to "#<id>" if the record has no usable title.

      Example: 1600 Market Street

    • address string | null required

      One unstructured string, exactly as stored, or null. There are no city / state / postal-code fields on a property and none are derived — see the six-things section in the API description.

      Example: 1600 Market St, Philadelphia, PA 19103

    • square_footage integer | null required

      null rather than 0 when unrecorded or unparseable. A square footage of zero is a claim, and a false one.

      Example: 329350

    • company string | null required

      Example: XR Advisors

    • compliance object required

      A property's compliance headline.

      • score number | null required

        A 0–1 fraction, rounded to 4 decimal places. The Confirmed portal shows this same number as a percentage — 0.8571 here is 85.71% there. null when the property has no score; never a substituted number.

        Documents that are expiring soon earn no credit: the score is (green + repaired) / total, so a property whose only document is expiring soon scores 0. That is the portal's behaviour and is reproduced here on purpose so the two never disagree.

        Example: 0.8571

      • risk_band string | null required

        The compliance risk band, as the lowercased band label. null whenever score is null — never a substituted band.

        One of: low, moderate, elevated, severe, null

        The compliance risk band, as the lowercased band label. null whenever score is null — never a substituted band.

    • primary_contact object required

      The property's primary contact. Only an email address is exposed — there is no name or phone field on this object, because there is no reliable backing value for either.

      • email string | null required
    • updated_at string | null required

      When the property record last changed, UTC. Compare against updated_since.

      Example: 2026-08-17T14:02:11Z

    • building_addresses array

      Per-building addresses, for multi-building properties. The key is absent entirely when the property has no list — it is never an empty array, because an empty array would read as 'we checked and there are none'.

      An array of:

      • label string | null required

        Example: Tower A

      • address string | null required

        Example: 1600 Market St

  • meta object required

    Present on collection responses only.

    • page integer required

      Example: 1

    • per_page integer required

      Example: 50

    • total integer required

      Total matching rows inside this key's scope. It is never the size of the whole estate, and it is computed after every filter has been applied, so it always agrees with the rows you can actually walk.

      Example: 26

    • total_pages integer required

      A ceiling, and at least 1 even when total is 0.

      Example: 1

Example response
{
    "data": [
        {
            "id": 1042,
            "name": "1600 Market Street",
            "address": "1600 Market St, Philadelphia, PA 19103",
            "square_footage": 329350,
            "company": "XR Advisors",
            "compliance": {
                "score": 0.8571,
                "risk_band": "low"
            },
            "primary_contact": {
                "email": "dana.reyes@example.com"
            },
            "updated_at": "2026-08-17T14:02:11Z",
            "building_addresses": [
                {
                    "label": "Tower A",
                    "address": "1600 Market St"
                },
                {
                    "label": "Tower B",
                    "address": "1620 Market St"
                }
            ]
        }
    ],
    "meta": {
        "page": 1,
        "per_page": 50,
        "total": 26,
        "total_pages": 1
    }
}
Code samples

cURL

curl -X GET 'https://confirmedlifesafety.com/wp-json/confirmed/v1/properties?page=1&per_page=50&updated_since=2026-08-17T14%3A02%3A11Z' \
  -H 'Authorization: Bearer YOUR_API_KEY'

JavaScript

const url = 'https://confirmedlifesafety.com/wp-json/confirmed/v1/properties?page=1&per_page=50&updated_since=2026-08-17T14%3A02%3A11Z';

const response = await fetch(url, {
  method: 'GET',
  headers: {
    'Authorization': 'Bearer YOUR_API_KEY'
  }
});

const data = await response.json();

Python

import requests

url = 'https://confirmedlifesafety.com/wp-json/confirmed/v1/properties?page=1&per_page=50&updated_since=2026-08-17T14%3A02%3A11Z'
headers = {'Authorization': 'Bearer YOUR_API_KEY'}

response = requests.get(url, headers=headers)

data = response.json()

GET /properties/{id}

Get one property

Requires an API key carrying the scope read:properties.

One property, plus a per-category document breakdown that the list route does not carry.

Parameters
NameInTypeDescription
idpathintegerrequired

The property id. An id outside this key's scope is a 404, never a 403.

Example: 1042

Responses
StatusMeaning
200

The property.

404

No such property, or a property outside this key's scope. The two are deliberately indistinguishable: a 403 would confirm the property exists to a caller not entitled to know.

401

Authentication failed. code is one of the six AuthErrorCode values. Every one is a 401 and never a 403, because a 403 would concede that the presented credential is real.

**No X-RateLimit-* headers**: this is decided before the key's counter is touched.

403

The key is genuine but does not carry the scope this operation requires. Always insufficient_scope.

**No X-RateLimit-* headers**: the scope check runs before the key's counter is touched.

429

Too many requests.

Two conditions answer with this same rate_limited code:

1. The key's quota — 120/minute (configurable per key) or 5,000/day. Carries Retry-After and the X-RateLimit-* headers, and message names which window was exceeded. 2. A per-IP failed-authentication throttle (20 failures/minute), which fires before any credential lookup. It carries no rate-limit headers, because no key has been identified to report a budget for.

500

Something failed on our side. The response never contains a stack trace, a SQL fragment or a filesystem path.

Response schema 200 application/json
  • data required

    A property, plus the per-category document breakdown that only the detail route carries.

    • id integer required

      Stable Confirmed property identifier. Use it as the path parameter everywhere.

      Example: 1042

    • name string required

      The display name, with the "Portfolio Dashboard – " prefix removed and HTML entities decoded, matching what the customer sees in their portal. Falls back to "#<id>" if the record has no usable title.

      Example: 1600 Market Street

    • address string | null required

      One unstructured string, exactly as stored, or null. There are no city / state / postal-code fields on a property and none are derived — see the six-things section in the API description.

      Example: 1600 Market St, Philadelphia, PA 19103

    • square_footage integer | null required

      null rather than 0 when unrecorded or unparseable. A square footage of zero is a claim, and a false one.

      Example: 329350

    • company string | null required

      Example: XR Advisors

    • compliance object required

      A property's compliance headline.

      • score number | null required

        A 0–1 fraction, rounded to 4 decimal places. The Confirmed portal shows this same number as a percentage — 0.8571 here is 85.71% there. null when the property has no score; never a substituted number.

        Documents that are expiring soon earn no credit: the score is (green + repaired) / total, so a property whose only document is expiring soon scores 0. That is the portal's behaviour and is reproduced here on purpose so the two never disagree.

        Example: 0.8571

      • risk_band string | null required

        The compliance risk band, as the lowercased band label. null whenever score is null — never a substituted band.

        One of: low, moderate, elevated, severe, null

        The compliance risk band, as the lowercased band label. null whenever score is null — never a substituted band.

    • primary_contact object required

      The property's primary contact. Only an email address is exposed — there is no name or phone field on this object, because there is no reliable backing value for either.

      • email string | null required
    • updated_at string | null required

      When the property record last changed, UTC. Compare against updated_since.

      Example: 2026-08-17T14:02:11Z

    • building_addresses array

      Per-building addresses, for multi-building properties. The key is absent entirely when the property has no list — it is never an empty array, because an empty array would read as 'we checked and there are none'.

      An array of:

      • label string | null required

        Example: Tower A

      • address string | null required

        Example: 1600 Market St

    • compliance required
      • score number | null required

        A 0–1 fraction, rounded to 4 decimal places. The Confirmed portal shows this same number as a percentage — 0.8571 here is 85.71% there. null when the property has no score; never a substituted number.

        Documents that are expiring soon earn no credit: the score is (green + repaired) / total, so a property whose only document is expiring soon scores 0. That is the portal's behaviour and is reproduced here on purpose so the two never disagree.

        Example: 0.8571

      • risk_band string | null required

        The compliance risk band, as the lowercased band label. null whenever score is null — never a substituted band.

        One of: low, moderate, elevated, severe, null

        The compliance risk band, as the lowercased band label. null whenever score is null — never a substituted band.

      • categories array required

        Per-category document counts for this property, sorted by category name. Present on the property detail response only.

        category is the stored value verbatim, so the two production spellings of one category (Invoices and invoices) appear as two separate rows. They are not merged, because merging them would mean inventing a mapping, and a mapping that is wrong for one importer silently combines unrelated categories. Documents with no category at all are grouped under Uncategorized.

        An array of:

        • category string required

          The stored category value, verbatim, or Uncategorized.

        • total integer required
        • green integer required

          Current.

        • amber integer required

          Expiring soon.

        • red integer required

          Expired.

        • undated integer required

          No usable expiration date recorded. Counted separately rather than as expired, which would overstate every property holding one. total == green + amber + red + undated.

Example response
{
    "data": {
        "id": 1042,
        "name": "1600 Market Street",
        "address": "1600 Market St, Philadelphia, PA 19103",
        "square_footage": 329350,
        "company": "XR Advisors",
        "compliance": {
            "score": 0.8571,
            "risk_band": "low",
            "categories": [
                {
                    "category": "Certificate of Insurance",
                    "total": 3,
                    "green": 2,
                    "amber": 1,
                    "red": 0,
                    "undated": 0
                },
                {
                    "category": "Inspection Report/Certificates",
                    "total": 22,
                    "green": 18,
                    "amber": 1,
                    "red": 2,
                    "undated": 1
                }
            ]
        },
        "primary_contact": {
            "email": "dana.reyes@example.com"
        },
        "updated_at": "2026-08-17T14:02:11Z",
        "building_addresses": [
            {
                "label": "Tower A",
                "address": "1600 Market St"
            },
            {
                "label": "Tower B",
                "address": "1620 Market St"
            }
        ]
    }
}
Code samples

cURL

curl -X GET 'https://confirmedlifesafety.com/wp-json/confirmed/v1/properties/1042' \
  -H 'Authorization: Bearer YOUR_API_KEY'

JavaScript

const url = 'https://confirmedlifesafety.com/wp-json/confirmed/v1/properties/1042';

const response = await fetch(url, {
  method: 'GET',
  headers: {
    'Authorization': 'Bearer YOUR_API_KEY'
  }
});

const data = await response.json();

Python

import requests

url = 'https://confirmedlifesafety.com/wp-json/confirmed/v1/properties/1042'
headers = {'Authorization': 'Bearer YOUR_API_KEY'}

response = requests.get(url, headers=headers)

data = response.json()

Documents

Compliance documents and their signed downloads.

GET /properties/{property_id}/documents

List a property's documents

Requires an API key carrying the scope read:documents.

Approved, unarchived documents for one property, newest first.

status and days_until_expiration are computed at request time rather than stored, so filtering happens after the rows are shaped — which is why total is exact and always matches the filtered set.

No download_url here. Fetch GET /documents/{id} for one.

Parameters
NameInTypeDescription
property_idpathintegerrequired

The property id. An id outside this key's scope is a 404, never a 403.

Example: 1042

pagequeryintegeroptional

1-based page number. Values below 1, and non-numeric values, are clamped rather than rejected.

Default: 1

per_pagequeryintegeroptional

Rows per page. Clamped, never rejected — per_page=9999 returns 200, per_page=0 returns 1, and a non-numeric value returns the default.

Default: 50

categoryquerystringoptional

Match on category_key. Both sides are normalised, so Fire Extinguishers, fire_extinguishers and FIRE-EXTINGUISHERS all select the same rows whichever spelling the importer stored. A value with no letters or digits is a 400.

Example: inspection_report_certificates

statusquerystringoptional

Exact match on the computed status. An unrecognised value is a 400, not an ignored filter.

Example: expiring_soon

expiring_within_daysqueryintegeroptional

Keep documents expiring between today and N days from now, inclusive. Already-expired documents are not included — they expired, they are not expiring; use status=expired. Documents with no expiration date are not included either.

Example: 60

updated_sincequerystringoptional

Keep documents uploaded at or after this instant — the filter reads uploaded_at, not a modification time. An unparseable value is a 400.

Example: 2026-08-01T00:00:00Z

If-None-Matchheaderstringoptional

The ETag from a previous response to this collection. A match returns 304 with no body. Weak validators (W/"…") and comma-separated lists are both understood.

Responses
StatusMeaning
200

A page of documents.

304

The collection is unchanged since the ETag you sent. No body.

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 the client no way to tell. insecure_transport when the request did not arrive over HTTPS.

404

No such property, or a property outside this key's scope. The two are deliberately indistinguishable: a 403 would confirm the property exists to a caller not entitled to know.

401

Authentication failed. code is one of the six AuthErrorCode values. Every one is a 401 and never a 403, because a 403 would concede that the presented credential is real.

**No X-RateLimit-* headers**: this is decided before the key's counter is touched.

403

The key is genuine but does not carry the scope this operation requires. Always insufficient_scope.

**No X-RateLimit-* headers**: the scope check runs before the key's counter is touched.

429

Too many requests.

Two conditions answer with this same rate_limited code:

1. The key's quota — 120/minute (configurable per key) or 5,000/day. Carries Retry-After and the X-RateLimit-* headers, and message names which window was exceeded. 2. A per-IP failed-authentication throttle (20 failures/minute), which fires before any credential lookup. It carries no rate-limit headers, because no key has been identified to report a budget for.

500

Something failed on our side. The response never contains a stack trace, a SQL fragment or a filesystem path.

Response schema 200 application/json
  • data array required

    An array of:

    • id integer required

      Example: 9912

    • property_id integer required

      The property this document is filed against.

      Example: 1042

    • category string | null required

      The stored category verbatim, so it matches what the customer sees in their portal. Production holds two spellings of several categories (Inspection Report/Certificates and inspection_report_certificates) depending on which importer wrote the row; both are returned as stored.

      This is a document type, not a discipline. It does not tell you what was inspected.

      Example: Inspection Report/Certificates

    • category_key string | null required

      category normalised for switching on: lowercased, every run of non-alphanumeric characters collapsed to a single _, trimmed.

      Lossy and not reversible. Punctuation is erased rather than encoded, so Inspection Report/Certificates and Inspection Report - Certificates deliberately collide — as do the label and slug spellings of the same category, which is the point.

      Nullable for two indistinguishable reasons: the row has no category, or its category contains no alphanumeric characters at all. The API does not tell you which.

      Not a join key, and not a discipline. There is no discipline field in this API; Elevators / Sprinkler / Fire Panel live only in Life Safety Interval post meta, matched by filename, so a client reading category_key learns nothing about what was inspected.

      Example: inspection_report_certificates

    • file_name string | null required

      Example: FE_Annual_2026.pdf

    • uploaded_at string | null required

      When the document was uploaded, UTC. This is what updated_since filters on.

      Example: 2026-03-06T18:22:00Z

    • inspection_date string | null required

      Calendar day, no time. null when unrecorded.

      Example: 2026-03-04

    • expiration_date string | null required

      Calendar day, no time. null when unrecorded.

      Example: 2027-03-04

    • status string required

      The document's compliance state, computed at request time from the shared compliance rules — the same rules and the same profile the portal uses, so the two always agree.

      • current — in date.
      • expiring_soon — inside the configurable amber window (30 days by default, editable by Confirmed, so do not hardcode it).
      • expired — past its expiration date.
      • scheduled — expired or expiring, but with a booked re-inspection within the last 14 days. After that grace it reverts to what its expiry says.
      • no_expiration — no usable expiration date recorded. days_until_expiration is null in this case, never 0.

      One of: current, expiring_soon, expired, scheduled, no_expiration

      The document's compliance state, computed at request time from the shared compliance rules — the same rules and the same profile the portal uses, so the two always agree.

      • current — in date.
      • expiring_soon — inside the configurable amber window (30 days by default, editable by Confirmed, so do not hardcode it).
      • expired — past its expiration date.
      • scheduled — expired or expiring, but with a booked re-inspection within the last 14 days. After that grace it reverts to what its expiry says.
      • no_expiration — no usable expiration date recorded. days_until_expiration is null in this case, never 0.
    • days_until_expiration integer | null required

      Whole days from today, measured day-to-day. Negative when the document has already expired. null, never 0, when no expiration date is recorded — 0 would read as 'expires today'.

      Example: 198

    • vendor required

      AI-extracted from the document itself, and frequently null. There is no vendor column; this is whatever the document parse read off the page. It is not validated against Confirmed's vendor records and is not an identifier — treat it as a hint.

      Spelled as oneOf rather than as a nullable $ref: in OpenAPI 3.1 a type of ["object", "null"] alongside an allOf of the object schema is self-contradictory for the null case, because the allOf branch still demands an object.

  • meta object required

    Present on collection responses only.

    • page integer required

      Example: 1

    • per_page integer required

      Example: 50

    • total integer required

      Total matching rows inside this key's scope. It is never the size of the whole estate, and it is computed after every filter has been applied, so it always agrees with the rows you can actually walk.

      Example: 26

    • total_pages integer required

      A ceiling, and at least 1 even when total is 0.

      Example: 1

Example response
{
    "data": [
        {
            "id": 9912,
            "property_id": 1042,
            "category": "Inspection Report/Certificates",
            "category_key": "inspection_report_certificates",
            "file_name": "FE_Annual_2026.pdf",
            "uploaded_at": "2026-03-06T18:22:00Z",
            "inspection_date": "2026-03-04",
            "expiration_date": "2027-03-04",
            "status": "current",
            "days_until_expiration": 198,
            "vendor": {
                "name": "Keystone Fire Protection"
            }
        }
    ],
    "meta": {
        "page": 1,
        "per_page": 50,
        "total": 28,
        "total_pages": 1
    }
}
Code samples

cURL

curl -X GET 'https://confirmedlifesafety.com/wp-json/confirmed/v1/properties/1042/documents?page=1&per_page=50&category=inspection_report_certificates&status=expiring_soon&expiring_within_days=60&updated_since=2026-08-01T00%3A00%3A00Z' \
  -H 'Authorization: Bearer YOUR_API_KEY'

JavaScript

const url = 'https://confirmedlifesafety.com/wp-json/confirmed/v1/properties/1042/documents?page=1&per_page=50&category=inspection_report_certificates&status=expiring_soon&expiring_within_days=60&updated_since=2026-08-01T00%3A00%3A00Z';

const response = await fetch(url, {
  method: 'GET',
  headers: {
    'Authorization': 'Bearer YOUR_API_KEY'
  }
});

const data = await response.json();

Python

import requests

url = 'https://confirmedlifesafety.com/wp-json/confirmed/v1/properties/1042/documents?page=1&per_page=50&category=inspection_report_certificates&status=expiring_soon&expiring_within_days=60&updated_since=2026-08-01T00%3A00%3A00Z'
headers = {'Authorization': 'Bearer YOUR_API_KEY'}

response = requests.get(url, headers=headers)

data = response.json()

POST /properties/{property_id}/documents

Upload a document

Requires an API key carrying the scope write:uploads.

The one write operation in this API. Submit a file as multipart/form-data under the part name file; it is stored and then handed to Confirmed's normal document pipeline — the same one a portal upload goes through — which extracts inspection and expiration dates, detects the category, and creates or updates the life-safety record.

The 201 body is exactly the GET /documents/{id} response for the created row, read back through that route rather than shaped separately, so the round trip is true by construction.

The pipeline may file the document against a different property than the one in the path: it climbs a life-safety child page to its parent, and its address fallback can re-home a row outright. A row this key may not see is not returned at any status.

File validation is strict, and stricter than Confirmed's mobile upload endpoint. Three independent things must agree: the file's measured content (by magic number), the Content-Type you declare for the part, and the file's extension. A mismatch in any of them is one invalid_type error that does not say which — telling you would turn this into an oracle for probing how far a crafted file got. Accepted: JPEG, PNG, GIF, PDF. Maximum 35 MB. Send exactly one file; a multi-file part is refused rather than partly processed.

Parameters
NameInTypeDescription
property_idpathintegerrequired

The property id. An id outside this key's scope is a 404, never a 403.

Example: 1042

Request body

multipart/form-data

  • file string required

    One JPEG, PNG, GIF or PDF, at most 35 MB (36700160 bytes).

Responses
StatusMeaning
201

The document was stored and processed.

400

The upload was rejected. no_file — the request carried no file. upload_failed — it carried one that did not arrive completely. file_too_large — over 35 MB. invalid_type — content, declared type and extension do not all agree, or the type is not accepted.

404

No such property, or a property outside this key's scope. The two are deliberately indistinguishable: a 403 would confirm the property exists to a caller not entitled to know.

502

upload_failed — the file was accepted but could not be stored or processed. Safe to retry.

503

upload_unavailable — document processing is temporarily unavailable. Nothing was stored. Retry shortly.

401

Authentication failed. code is one of the six AuthErrorCode values. Every one is a 401 and never a 403, because a 403 would concede that the presented credential is real.

**No X-RateLimit-* headers**: this is decided before the key's counter is touched.

403

The key is genuine but does not carry the scope this operation requires. Always insufficient_scope.

**No X-RateLimit-* headers**: the scope check runs before the key's counter is touched.

429

Too many requests.

Two conditions answer with this same rate_limited code:

1. The key's quota — 120/minute (configurable per key) or 5,000/day. Carries Retry-After and the X-RateLimit-* headers, and message names which window was exceeded. 2. A per-IP failed-authentication throttle (20 failures/minute), which fires before any credential lookup. It carries no rate-limit headers, because no key has been identified to report a budget for.

500

Something failed on our side. The response never contains a stack trace, a SQL fragment or a filesystem path.

Response schema 201 application/json
  • data object required

    A document, plus the short-lived signed download URL that only this route mints.

    • id integer required

      Example: 9912

    • property_id integer required

      The property this document is filed against.

      Example: 1042

    • category string | null required

      The stored category verbatim, so it matches what the customer sees in their portal. Production holds two spellings of several categories (Inspection Report/Certificates and inspection_report_certificates) depending on which importer wrote the row; both are returned as stored.

      This is a document type, not a discipline. It does not tell you what was inspected.

      Example: Inspection Report/Certificates

    • category_key string | null required

      category normalised for switching on: lowercased, every run of non-alphanumeric characters collapsed to a single _, trimmed.

      Lossy and not reversible. Punctuation is erased rather than encoded, so Inspection Report/Certificates and Inspection Report - Certificates deliberately collide — as do the label and slug spellings of the same category, which is the point.

      Nullable for two indistinguishable reasons: the row has no category, or its category contains no alphanumeric characters at all. The API does not tell you which.

      Not a join key, and not a discipline. There is no discipline field in this API; Elevators / Sprinkler / Fire Panel live only in Life Safety Interval post meta, matched by filename, so a client reading category_key learns nothing about what was inspected.

      Example: inspection_report_certificates

    • file_name string | null required

      Example: FE_Annual_2026.pdf

    • uploaded_at string | null required

      When the document was uploaded, UTC. This is what updated_since filters on.

      Example: 2026-03-06T18:22:00Z

    • inspection_date string | null required

      Calendar day, no time. null when unrecorded.

      Example: 2026-03-04

    • expiration_date string | null required

      Calendar day, no time. null when unrecorded.

      Example: 2027-03-04

    • status string required

      The document's compliance state, computed at request time from the shared compliance rules — the same rules and the same profile the portal uses, so the two always agree.

      • current — in date.
      • expiring_soon — inside the configurable amber window (30 days by default, editable by Confirmed, so do not hardcode it).
      • expired — past its expiration date.
      • scheduled — expired or expiring, but with a booked re-inspection within the last 14 days. After that grace it reverts to what its expiry says.
      • no_expiration — no usable expiration date recorded. days_until_expiration is null in this case, never 0.

      One of: current, expiring_soon, expired, scheduled, no_expiration

      The document's compliance state, computed at request time from the shared compliance rules — the same rules and the same profile the portal uses, so the two always agree.

      • current — in date.
      • expiring_soon — inside the configurable amber window (30 days by default, editable by Confirmed, so do not hardcode it).
      • expired — past its expiration date.
      • scheduled — expired or expiring, but with a booked re-inspection within the last 14 days. After that grace it reverts to what its expiry says.
      • no_expiration — no usable expiration date recorded. days_until_expiration is null in this case, never 0.
    • days_until_expiration integer | null required

      Whole days from today, measured day-to-day. Negative when the document has already expired. null, never 0, when no expiration date is recorded — 0 would read as 'expires today'.

      Example: 198

    • vendor required

      AI-extracted from the document itself, and frequently null. There is no vendor column; this is whatever the document parse read off the page. It is not validated against Confirmed's vendor records and is not an identifier — treat it as a hint.

      Spelled as oneOf rather than as a nullable $ref: in OpenAPI 3.1 a type of ["object", "null"] alongside an allOf of the object schema is self-contradictory for the null case, because the allOf branch still demands an object.

    • download_url string | null required

      A signed URL for the file's bytes. Single use, with a TTL of 15 minutes, minted fresh on every request to this route.

      It appears only here. Document list responses never carry one, because a signed URL is a live credential and a page of 200 documents would put 200 spendable credentials into a single response body.

      Redeeming it consumes it: a second request to the same URL is a 404, identical to the 404 for expiry, tampering, or the key having lost access to the document since minting. Fetch this route again to mint a fresh one.

      null in the rare case the URL could not be minted; the document record itself is still valid.

    • download_url_expires_at string | null required

      When download_url stops working, UTC. Read from the token itself, so it cannot drift from it.

      Example: 2026-03-06T18:37:00Z

Example response
{
    "data": {
        "id": 9912,
        "property_id": 1042,
        "category": "Inspection Report/Certificates",
        "category_key": "inspection_report_certificates",
        "file_name": "FE_Annual_2026.pdf",
        "uploaded_at": "2026-03-06T18:22:00Z",
        "inspection_date": "2026-03-04",
        "expiration_date": "2027-03-04",
        "status": "current",
        "days_until_expiration": 198,
        "vendor": {
            "name": "Keystone Fire Protection"
        },
        "download_url": "https://confirmedlifesafety.com/wp-json/confirmed/v1/download/eyJkb2NfaWQiOjk5MTIsImtleV9pZCI6NywiZXhwIjoxNzk1MDE0NTIwLCJub25jZSI6ImE5ZjMifQ.Kx7pQ2mS1vHhTt0nJ8zY4bLdWc6RfAeUgN3iOxVpQlM",
        "download_url_expires_at": "2026-03-06T18:37:00Z"
    }
}
Code samples

cURL

curl -X POST 'https://confirmedlifesafety.com/wp-json/confirmed/v1/properties/1042/documents' \
  -H 'Authorization: Bearer YOUR_API_KEY' \
  -F 'file=@/path/to/FE_Annual_2026.pdf;type=application/pdf'

JavaScript

const url = 'https://confirmedlifesafety.com/wp-json/confirmed/v1/properties/1042/documents';

const formData = new FormData();
formData.append('file', fileInput.files[0]); // FE_Annual_2026.pdf (application/pdf)

const response = await fetch(url, {
  method: 'POST',
  headers: {
    'Authorization': 'Bearer YOUR_API_KEY'
  },
  body: formData
});

const data = await response.json();

Python

import requests

url = 'https://confirmedlifesafety.com/wp-json/confirmed/v1/properties/1042/documents'
headers = {'Authorization': 'Bearer YOUR_API_KEY'}

with open('FE_Annual_2026.pdf', 'rb') as fh:
    files = {'file': ('FE_Annual_2026.pdf', fh, 'application/pdf')}
    response = requests.post(url, headers=headers, files=files)

data = response.json()

GET /documents/{id}

Get one document, with a download URL

Requires an API key carrying the scope read:documents.

One document, plus download_url and download_url_expires_at. This is the only route that mints a download URL; the list route never carries one.

Each call mints a fresh single-use URL with a 15 minute TTL, so calling this route twice gives you two different URLs and spends neither.

Parameters
NameInTypeDescription
idpathintegerrequired

The document id. A document belonging to a property outside this key's scope returns exactly the same 404 as one that does not exist — the two are byte-identical, so the route cannot be walked to learn which ids exist.

Example: 9912

Responses
StatusMeaning
200

The document.

404

No such document, or a document outside this key's scope. Byte-identical in both cases, so the route cannot be walked as an oracle for document ids.

401

Authentication failed. code is one of the six AuthErrorCode values. Every one is a 401 and never a 403, because a 403 would concede that the presented credential is real.

**No X-RateLimit-* headers**: this is decided before the key's counter is touched.

403

The key is genuine but does not carry the scope this operation requires. Always insufficient_scope.

**No X-RateLimit-* headers**: the scope check runs before the key's counter is touched.

429

Too many requests.

Two conditions answer with this same rate_limited code:

1. The key's quota — 120/minute (configurable per key) or 5,000/day. Carries Retry-After and the X-RateLimit-* headers, and message names which window was exceeded. 2. A per-IP failed-authentication throttle (20 failures/minute), which fires before any credential lookup. It carries no rate-limit headers, because no key has been identified to report a budget for.

500

Something failed on our side. The response never contains a stack trace, a SQL fragment or a filesystem path.

Response schema 200 application/json
  • data object required

    A document, plus the short-lived signed download URL that only this route mints.

    • id integer required

      Example: 9912

    • property_id integer required

      The property this document is filed against.

      Example: 1042

    • category string | null required

      The stored category verbatim, so it matches what the customer sees in their portal. Production holds two spellings of several categories (Inspection Report/Certificates and inspection_report_certificates) depending on which importer wrote the row; both are returned as stored.

      This is a document type, not a discipline. It does not tell you what was inspected.

      Example: Inspection Report/Certificates

    • category_key string | null required

      category normalised for switching on: lowercased, every run of non-alphanumeric characters collapsed to a single _, trimmed.

      Lossy and not reversible. Punctuation is erased rather than encoded, so Inspection Report/Certificates and Inspection Report - Certificates deliberately collide — as do the label and slug spellings of the same category, which is the point.

      Nullable for two indistinguishable reasons: the row has no category, or its category contains no alphanumeric characters at all. The API does not tell you which.

      Not a join key, and not a discipline. There is no discipline field in this API; Elevators / Sprinkler / Fire Panel live only in Life Safety Interval post meta, matched by filename, so a client reading category_key learns nothing about what was inspected.

      Example: inspection_report_certificates

    • file_name string | null required

      Example: FE_Annual_2026.pdf

    • uploaded_at string | null required

      When the document was uploaded, UTC. This is what updated_since filters on.

      Example: 2026-03-06T18:22:00Z

    • inspection_date string | null required

      Calendar day, no time. null when unrecorded.

      Example: 2026-03-04

    • expiration_date string | null required

      Calendar day, no time. null when unrecorded.

      Example: 2027-03-04

    • status string required

      The document's compliance state, computed at request time from the shared compliance rules — the same rules and the same profile the portal uses, so the two always agree.

      • current — in date.
      • expiring_soon — inside the configurable amber window (30 days by default, editable by Confirmed, so do not hardcode it).
      • expired — past its expiration date.
      • scheduled — expired or expiring, but with a booked re-inspection within the last 14 days. After that grace it reverts to what its expiry says.
      • no_expiration — no usable expiration date recorded. days_until_expiration is null in this case, never 0.

      One of: current, expiring_soon, expired, scheduled, no_expiration

      The document's compliance state, computed at request time from the shared compliance rules — the same rules and the same profile the portal uses, so the two always agree.

      • current — in date.
      • expiring_soon — inside the configurable amber window (30 days by default, editable by Confirmed, so do not hardcode it).
      • expired — past its expiration date.
      • scheduled — expired or expiring, but with a booked re-inspection within the last 14 days. After that grace it reverts to what its expiry says.
      • no_expiration — no usable expiration date recorded. days_until_expiration is null in this case, never 0.
    • days_until_expiration integer | null required

      Whole days from today, measured day-to-day. Negative when the document has already expired. null, never 0, when no expiration date is recorded — 0 would read as 'expires today'.

      Example: 198

    • vendor required

      AI-extracted from the document itself, and frequently null. There is no vendor column; this is whatever the document parse read off the page. It is not validated against Confirmed's vendor records and is not an identifier — treat it as a hint.

      Spelled as oneOf rather than as a nullable $ref: in OpenAPI 3.1 a type of ["object", "null"] alongside an allOf of the object schema is self-contradictory for the null case, because the allOf branch still demands an object.

    • download_url string | null required

      A signed URL for the file's bytes. Single use, with a TTL of 15 minutes, minted fresh on every request to this route.

      It appears only here. Document list responses never carry one, because a signed URL is a live credential and a page of 200 documents would put 200 spendable credentials into a single response body.

      Redeeming it consumes it: a second request to the same URL is a 404, identical to the 404 for expiry, tampering, or the key having lost access to the document since minting. Fetch this route again to mint a fresh one.

      null in the rare case the URL could not be minted; the document record itself is still valid.

    • download_url_expires_at string | null required

      When download_url stops working, UTC. Read from the token itself, so it cannot drift from it.

      Example: 2026-03-06T18:37:00Z

Example response
{
    "data": {
        "id": 9912,
        "property_id": 1042,
        "category": "Inspection Report/Certificates",
        "category_key": "inspection_report_certificates",
        "file_name": "FE_Annual_2026.pdf",
        "uploaded_at": "2026-03-06T18:22:00Z",
        "inspection_date": "2026-03-04",
        "expiration_date": "2027-03-04",
        "status": "current",
        "days_until_expiration": 198,
        "vendor": {
            "name": "Keystone Fire Protection"
        },
        "download_url": "https://confirmedlifesafety.com/wp-json/confirmed/v1/download/eyJkb2NfaWQiOjk5MTIsImtleV9pZCI6NywiZXhwIjoxNzk1MDE0NTIwLCJub25jZSI6ImE5ZjMifQ.Kx7pQ2mS1vHhTt0nJ8zY4bLdWc6RfAeUgN3iOxVpQlM",
        "download_url_expires_at": "2026-03-06T18:37:00Z"
    }
}
Code samples

cURL

curl -X GET 'https://confirmedlifesafety.com/wp-json/confirmed/v1/documents/9912' \
  -H 'Authorization: Bearer YOUR_API_KEY'

JavaScript

const url = 'https://confirmedlifesafety.com/wp-json/confirmed/v1/documents/9912';

const response = await fetch(url, {
  method: 'GET',
  headers: {
    'Authorization': 'Bearer YOUR_API_KEY'
  }
});

const data = await response.json();

Python

import requests

url = 'https://confirmedlifesafety.com/wp-json/confirmed/v1/documents/9912'
headers = {'Authorization': 'Bearer YOUR_API_KEY'}

response = requests.get(url, headers=headers)

data = response.json()

GET /download/{token}

Redeem a signed download URL

Public. No credential is required, and none should be sent.

Streams the document's bytes.

**This route takes no bearer key — the signed token is the credential.** That is why it is public: a browser following a link cannot set an Authorization header. Do not construct these URLs; take them from download_url on GET /documents/{id} and follow them as-is.

Single use. Redemption consumes the token. A replay, an expired token, a tampered token, a revoked or expired key, a key that has since lost access to the document, an archived document and a missing file all return the same 404 with the same body — anything else would make this route an oracle for document ids, key states and the filesystem at once.

The key's access is re-checked at redemption, so revoking a key or removing a property invalidates outstanding URLs immediately rather than leaving a 15-minute window.

The response is Cache-Control: private, no-store and carries Content-Disposition: attachment with the document's own filename. The underlying storage path is never disclosed.

Parameters
NameInTypeDescription
tokenpathstringrequired

The opaque signed token from a download_url. Do not construct or modify it.

Responses
StatusMeaning
200

The file.

404

The one answer every failure gets — expiry, replay, tampering, revocation, loss of access, archival and a missing file alike.

Response schema 200 application/pdf
Response schema 200 image/jpeg
Response schema 200 image/png
Response schema 200 application/octet-stream
Example response
"<binary>"
Code samples

cURL

curl -X GET 'https://confirmedlifesafety.com/wp-json/confirmed/v1/download/eyJkb2NfaWQiOjk5MTIsImtleV9pZCI6NyJ9.signature-goes-here'

JavaScript

const url = 'https://confirmedlifesafety.com/wp-json/confirmed/v1/download/eyJkb2NfaWQiOjk5MTIsImtleV9pZCI6NyJ9.signature-goes-here';

const response = await fetch(url, {
  method: 'GET'
});

const data = await response.json();

Python

import requests

url = 'https://confirmedlifesafety.com/wp-json/confirmed/v1/download/eyJkb2NfaWQiOjk5MTIsImtleV9pZCI6NyJ9.signature-goes-here'

response = requests.get(url)

data = response.json()

Compliance

Portfolio-level rollups.

GET /compliance/summary

Portfolio compliance rollup

Requires an API key carrying the scope read:compliance.

Everything a BI tool would otherwise page every property to compute: property count, mean score and risk band, document buckets, cumulative expiry windows, and a per-property score list.

This is a single-resource response — there is no meta, no pagination, and no ETag.

No parameters.

Responses
StatusMeaning
200

The rollup.

401

Authentication failed. code is one of the six AuthErrorCode values. Every one is a 401 and never a 403, because a 403 would concede that the presented credential is real.

**No X-RateLimit-* headers**: this is decided before the key's counter is touched.

403

The key is genuine but does not carry the scope this operation requires. Always insufficient_scope.

**No X-RateLimit-* headers**: the scope check runs before the key's counter is touched.

429

Too many requests.

Two conditions answer with this same rate_limited code:

1. The key's quota — 120/minute (configurable per key) or 5,000/day. Carries Retry-After and the X-RateLimit-* headers, and message names which window was exceeded. 2. A per-IP failed-authentication throttle (20 failures/minute), which fires before any credential lookup. It carries no rate-limit headers, because no key has been identified to report a budget for.

500

Something failed on our side. The response never contains a stack trace, a SQL fragment or a filesystem path.

Response schema 200 application/json
  • data object required

    A portfolio rollup over everything this key can see — one call instead of paging every property.

    • properties integer required

      How many properties are in this key's scope.

      Example: 26

    • score number | null required

      The mean of the per-property scores, over scored properties only, as a 0–1 fraction. A property with nothing to score yet is 'no data', not 'zero', and is excluded from the mean rather than folded in as 0 — conflating those two is the most dangerous wrong answer this endpoint could give. null when no property in scope has a score at all.

      Example: 0.8412

    • risk_band string | null required

      The compliance risk band, as the lowercased band label. null whenever score is null — never a substituted band.

      One of: low, moderate, elevated, severe, null

      The compliance risk band, as the lowercased band label. null whenever score is null — never a substituted band.

    • documents object required

      total is always exactly current + expiring_soon + expired + no_expiration, so no row silently disappears from the count.

      current / expiring_soon / expired use the configurable amber window — the same one the portal uses.

      • total integer required

        Example: 742

      • current integer required

        Example: 601

      • expiring_soon integer required

        Example: 34

      • expired integer required

        Example: 52

      • no_expiration integer required

        No usable expiration date. Excluded from every expiry bucket, included in total.

        Example: 55

    • expiring object required

      Fixed, absolute calendar windows — not the configurable amber window, and deliberately cumulative: a document expiring in 10 days is counted in all three, so in_30_days can be read on its own as 'act this month'. Only future expirations count; an already-expired document is in documents.expired and in none of these.

      • in_30_days integer required

        Example: 34

      • in_60_days integer required

        Example: 58

      • in_90_days integer required

        Example: 91

    • by_property array required

      Per-property scores. Capped at 500 entries so an enormous portfolio cannot produce an unbounded body. There is no page 2 of this array — use GET /properties when you need to walk everything. The cap applies to this listing only: properties, score, documents and expiring always cover the full scope.

      An array of:

      • property_id integer required

        Example: 1042

      • score number | null required

        A 0–1 fraction, or null when the property has no score. See Compliance.score.

        Example: 0.8571

      • risk_band string | null required

        The compliance risk band, as the lowercased band label. null whenever score is null — never a substituted band.

        One of: low, moderate, elevated, severe, null

        The compliance risk band, as the lowercased band label. null whenever score is null — never a substituted band.

    • by_property_capped boolean required

      true when by_property was truncated. Truncation is never silent.

      Example: false

Example response
{
    "data": {
        "properties": 26,
        "score": 0.8412,
        "risk_band": "low",
        "documents": {
            "total": 742,
            "current": 601,
            "expiring_soon": 34,
            "expired": 52,
            "no_expiration": 55
        },
        "expiring": {
            "in_30_days": 34,
            "in_60_days": 58,
            "in_90_days": 91
        },
        "by_property": [
            {
                "property_id": 1042,
                "score": 0.8571,
                "risk_band": "low"
            },
            {
                "property_id": 1043,
                "score": null,
                "risk_band": null
            }
        ],
        "by_property_capped": false
    }
}
Code samples

cURL

curl -X GET 'https://confirmedlifesafety.com/wp-json/confirmed/v1/compliance/summary' \
  -H 'Authorization: Bearer YOUR_API_KEY'

JavaScript

const url = 'https://confirmedlifesafety.com/wp-json/confirmed/v1/compliance/summary';

const response = await fetch(url, {
  method: 'GET',
  headers: {
    'Authorization': 'Bearer YOUR_API_KEY'
  }
});

const data = await response.json();

Python

import requests

url = 'https://confirmedlifesafety.com/wp-json/confirmed/v1/compliance/summary'
headers = {'Authorization': 'Bearer YOUR_API_KEY'}

response = requests.get(url, headers=headers)

data = response.json()

Tasks

Confirmed's open to-do list for your properties.

GET /tasks

List open tasks

Requires an API key carrying the scope read:tasks.

Confirmed's open to-do list across this key's properties. Only open items are ever returned — resolved and voided tasks do not appear, whatever you pass as status.

Confirmed's internal workflow bookkeeping (vendor outreach state, AI-suggested resolutions, internal notes, snooze scheduling, internal user ids) is deliberately not exposed.

Parameters
NameInTypeDescription
pagequeryintegeroptional

1-based page number. Values below 1, and non-numeric values, are clamped rather than rejected.

Default: 1

per_pagequeryintegeroptional

Rows per page. Clamped, never rejected — per_page=9999 returns 200, per_page=0 returns 1, and a non-numeric value returns the default.

Default: 50

property_idqueryintegeroptional

Narrow to one property. A property outside this key's scope returns an empty page rather than an error, indistinguishable from that property having no open tasks — the same choice the detail routes make with their 404.

Example: 1042

statusquerystringoptional

Narrow within the open set. This filter can only narrow, never widen: status=done, and any value not listed here, matches nothing and returns an empty page rather than an error.

Example: open

If-None-Matchheaderstringoptional

The ETag from a previous response to this collection. A match returns 304 with no body. Weak validators (W/"…") and comma-separated lists are both understood.

Responses
StatusMeaning
200

A page of open tasks.

304

The collection is unchanged since the ETag you sent. No body.

401

Authentication failed. code is one of the six AuthErrorCode values. Every one is a 401 and never a 403, because a 403 would concede that the presented credential is real.

**No X-RateLimit-* headers**: this is decided before the key's counter is touched.

403

The key is genuine but does not carry the scope this operation requires. Always insufficient_scope.

**No X-RateLimit-* headers**: the scope check runs before the key's counter is touched.

429

Too many requests.

Two conditions answer with this same rate_limited code:

1. The key's quota — 120/minute (configurable per key) or 5,000/day. Carries Retry-After and the X-RateLimit-* headers, and message names which window was exceeded. 2. A per-IP failed-authentication throttle (20 failures/minute), which fires before any credential lookup. It carries no rate-limit headers, because no key has been identified to report a budget for.

500

Something failed on our side. The response never contains a stack trace, a SQL fragment or a filesystem path.

Response schema 200 application/json
  • data array required

    An array of:

    • id integer required

      Example: 4471

    • property_id integer required

      Example: 1042

    • title string required

      Example: Fire extinguisher certificate expired

    • description string | null required

      Example: The most recent annual certificate expired on 2026-07-14 and no replacement has been received.

    • source_type string required

      Why the task exists, as Confirmed's task engine classified it.

      Example: expiring_document

    • status string required

      A stable four-value vocabulary, mapped from Confirmed's internal task state machine so that internal renames cannot break your integration. done is declared for completeness and is never returned — this endpoint only ever carries open items.

      One of: open, in_progress, blocked, done

      A stable four-value vocabulary, mapped from Confirmed's internal task state machine so that internal renames cannot break your integration. done is declared for completeness and is never returned — this endpoint only ever carries open items.

    • due_date string | null required

      Example: 2026-09-01T00:00:00Z

    • created_at string | null required

      Example: 2026-08-04T13:11:07Z

  • meta object required

    Present on collection responses only.

    • page integer required

      Example: 1

    • per_page integer required

      Example: 50

    • total integer required

      Total matching rows inside this key's scope. It is never the size of the whole estate, and it is computed after every filter has been applied, so it always agrees with the rows you can actually walk.

      Example: 26

    • total_pages integer required

      A ceiling, and at least 1 even when total is 0.

      Example: 1

Example response
{
    "data": [
        {
            "id": 4471,
            "property_id": 1042,
            "title": "Fire extinguisher certificate expired",
            "description": "The most recent annual certificate expired on 2026-07-14 and no replacement has been received.",
            "source_type": "expiring_document",
            "status": "open",
            "due_date": "2026-09-01T00:00:00Z",
            "created_at": "2026-08-04T13:11:07Z"
        }
    ],
    "meta": {
        "page": 1,
        "per_page": 50,
        "total": 12,
        "total_pages": 1
    }
}
Code samples

cURL

curl -X GET 'https://confirmedlifesafety.com/wp-json/confirmed/v1/tasks?page=1&per_page=50&property_id=1042&status=open' \
  -H 'Authorization: Bearer YOUR_API_KEY'

JavaScript

const url = 'https://confirmedlifesafety.com/wp-json/confirmed/v1/tasks?page=1&per_page=50&property_id=1042&status=open';

const response = await fetch(url, {
  method: 'GET',
  headers: {
    'Authorization': 'Bearer YOUR_API_KEY'
  }
});

const data = await response.json();

Python

import requests

url = 'https://confirmedlifesafety.com/wp-json/confirmed/v1/tasks?page=1&per_page=50&property_id=1042&status=open'
headers = {'Authorization': 'Bearer YOUR_API_KEY'}

response = requests.get(url, headers=headers)

data = response.json()

Service

Credential check and the machine-readable contract.

GET /ping

Verify a key

Requires an API key carrying the scope read:properties.

Answers 200 with { "data": { "ok": true } } for any key carrying read:properties. The cheapest way to confirm a credential, its scope resolution and your transport are all working before writing an integration. It reads no customer data.

No parameters.

Responses
StatusMeaning
200

The key is valid.

401

Authentication failed. code is one of the six AuthErrorCode values. Every one is a 401 and never a 403, because a 403 would concede that the presented credential is real.

**No X-RateLimit-* headers**: this is decided before the key's counter is touched.

403

The key is genuine but does not carry the scope this operation requires. Always insufficient_scope.

**No X-RateLimit-* headers**: the scope check runs before the key's counter is touched.

429

Too many requests.

Two conditions answer with this same rate_limited code:

1. The key's quota — 120/minute (configurable per key) or 5,000/day. Carries Retry-After and the X-RateLimit-* headers, and message names which window was exceeded. 2. A per-IP failed-authentication throttle (20 failures/minute), which fires before any credential lookup. It carries no rate-limit headers, because no key has been identified to report a budget for.

500

Something failed on our side. The response never contains a stack trace, a SQL fragment or a filesystem path.

Response schema 200 application/json
  • data object required
    • ok boolean required

      Example: true

Example response
{
    "data": {
        "ok": true
    }
}
Code samples

cURL

curl -X GET 'https://confirmedlifesafety.com/wp-json/confirmed/v1/ping' \
  -H 'Authorization: Bearer YOUR_API_KEY'

JavaScript

const url = 'https://confirmedlifesafety.com/wp-json/confirmed/v1/ping';

const response = await fetch(url, {
  method: 'GET',
  headers: {
    'Authorization': 'Bearer YOUR_API_KEY'
  }
});

const data = await response.json();

Python

import requests

url = 'https://confirmedlifesafety.com/wp-json/confirmed/v1/ping'
headers = {'Authorization': 'Bearer YOUR_API_KEY'}

response = requests.get(url, headers=headers)

data = response.json()

GET /openapi.json

This document

Public. No credential is required, and none should be sent.

This specification, as JSON. Public and unauthenticated — it is the instructions for obtaining the credential every other route requires, so putting it behind that credential would make a customer's first step impossible. It contains no customer data of any kind.

Import it into Postman or Insomnia, or generate a client from it with openapi-generator.

Unlike /download/{token} — the other public route — this one is deliberately cacheable: the bytes are identical for every caller, so it is served Cache-Control: public, max-age=3600 rather than no-store. The two public routes are public for unrelated reasons and must not be given the same directives.

servers[0].url in the served copy always names the host that served it.

No parameters.

Responses
StatusMeaning
200

The specification.

500

spec_unavailable — the specification could not be read on our side.

Response schema 200 application/json

An OpenAPI 3.1.0 document.

Code samples

cURL

curl -X GET 'https://confirmedlifesafety.com/wp-json/confirmed/v1/openapi.json'

JavaScript

const url = 'https://confirmedlifesafety.com/wp-json/confirmed/v1/openapi.json';

const response = await fetch(url, {
  method: 'GET'
});

const data = await response.json();

Python

import requests

url = 'https://confirmedlifesafety.com/wp-json/confirmed/v1/openapi.json'

response = requests.get(url)

data = response.json()

Need a key, or stuck on something?

Keys are issued by Confirmed rather than self-served, because a key inherits a named person’s portal access and issuing one is an access decision about a human being. Ask your Confirmed contact, or email support@confirmedlifesafety.com.

Request access