# RapidEye partner integration guide

RapidEye adds AI video inspections to any task or property-management system.
You provision a workspace when you sign a customer, you send us that customer's
property roster, your system asks us for a recording link when a task is
created, a cleaner records a walkthrough, RapidEye processes the inspection and
sends you back a report link.

This guide is written so a coding agent can implement the integration directly.

**Roster first.** A workspace imports its complete property list before any
recording link is issued for it. A `property_id` that is not in the roster you
sent is refused, not created.

## How it works

```mermaid
sequenceDiagram
    participant T as Your task system
    participant P as Your backend
    participant R as RapidEye
    participant W as Cleaner

    P->>R: POST /v1/workspaces (workspace_id, name) — once, when you sign a customer
    R-->>P: 201 (company_id)
    P->>R: POST /v1/roster-imports (complete property snapshot)
    R-->>P: 202 (import_id, status_url)
    R->>R: Import applies, its active properties switch on
    T->>P: Task created
    P->>R: GET /v1/recording-link (workspace_id, task_id, property_id)
    R-->>P: 200 (recording_url)
    P->>T: Save recording_url on the task, display to cleaner
    W->>R: Cleaner taps link, records walkthrough
    R->>P: POST inspection.recording_completed webhook, signed, checklist results
    R->>P: POST inspection.completed webhook, signed, carries report_url
    P->>T: Save report_url on the task
```

## Quickstart: the calls you need

Everything below this section is detail for these six steps. Build these and
you are live; the rest of the guide is optional.

**Once per customer**

1. **Authenticate** every request with `Authorization: Bearer rapideye_pk_...`.
2. **`POST /v1/workspaces`** with your `workspace_id` and the customer's name.
   Idempotent: calling it again for the same `workspace_id` returns the same
   account.
3. **`POST /v1/roster-imports?workspace_id=...`** with a required
   `Idempotency-Key` header and a body carrying `complete: true`,
   `observed_at` (when you read the list), `total_count` (the number of
   properties in the array) and `properties`, the customer's complete list.
   The import runs in the background: poll the returned `status_url` until
   `status` is `applied`. A `rejected` or `failed` import carries the reason
   in `error`; send the corrected roster with a **new** `Idempotency-Key`,
   because a key is only for retrying the identical request. Send the full
   list again whenever properties are added or removed. A property missing
   from a later list stops accepting recordings; its history is kept.

**An applied import switches the workspace on.** When an import reaches
`applied`, every property it lists as `active` is activated for inspections and
the account is put into service. You do not need to ask us to switch anything
on. Activation runs just after the import is marked `applied`, not in the same
instant, so a link request in the first few seconds can still answer
`property_not_activated`; wait a few seconds and ask again before doing anything
else. Until the first import applies, step 4 answers `roster_required`, and
`GET /v1/workspaces/{id}` shows where the account stands. If a property is
still off after that, the refusal tables below say what to do.

**Per task**

4. **`GET /v1/recording-link`** with `workspace_id`, `task_id` and
   `property_id` when a walkthrough is due. Put the returned `recording_url` on
   the task for the cleaner.
5. **Receive `inspection.completed`** at your webhook when the report is ready.
   Verify the signature, then dedupe on `event_id`. If the home has a
   checklist, `inspection.recording_completed` arrives first with every item's
   result: seconds after the walkthrough is sealed (safely saved on our side,
   usually under a minute after the cleaner taps Done), and at most about 2
   minutes after the seal.
6. **`GET /v1/inspections/{inspection_id}/findings?workspace_id=...`** to read
   what we found and open your follow-up task.

To keep a copy of the walkthrough video, call
`GET /v1/inspections/{inspection_id}/video?workspace_id=...` (the webhook's
`video_url`) and download the file from the link it returns.

**If you miss a webhook,** `GET /v1/inspections?workspace_id=...&task_id=...`
returns the same status and ids.

One walkthrough per task: a cleaner who reopens the link for a task already in
progress resumes it.

## Conventions

| Item | Value |
|---|---|
| Base URL | `https://api.rapideyeinspections.com`, bare paths |
| Responses | `application/json`; date-times ISO 8601 UTC. The one field named `timestamp` (a checklist item's moment in the video) is a number of seconds, not a date-time |
| Support | support@rapideyeinspections.com |
| IDs | `workspace_id`, `task_id`, `property_id` in requests are **yours**; `company_id`, `inspection_id` and `rapideye_property_id` are ours |

## Authentication (you calling us)

One long-lived bearer partner key, issued by RapidEye when we set you up:

```
Authorization: Bearer rapideye_pk_...
```

Server-side secret only; never ship it to a browser or mobile client. Unknown key
gets `401 invalid_key`; revoked key gets `403 key_revoked`; an integration we have
paused gets `403 partner_disabled`, which is not a problem with the key and is not
fixed by rotating one.

Every non-2xx response uses one envelope; branch on `error.code`, not message text:

```json
{ "error": { "code": "workspace_not_provisioned", "message": "..." } }
```

`code` and `message` are always present. `detail` and `next_steps` are optional
and are **omitted entirely** when they do not apply, never sent as `null` or as
an empty object, so test for the key before reading it. `workspace_not_provisioned`
above carries no specifics, which is why it has neither.

When `detail` is present it is an **object** carrying the machine-actionable
specifics for that code (which parameter was missing, which limit was exceeded,
which property id was not in the roster). Its keys vary by code, so read them per
code rather than assuming a fixed shape.

### Error codes

Every code this surface emits. The status is part of the contract too: branch on
the code, and treat any code you do not recognize as a failure of its status class.

| Code | Status | Meaning |
|---|---|---|
| `missing_parameter` | 400 | A required parameter, field or header is absent or blank. `detail.parameter` names it. |
| `invalid_request` | 400 | The body is unusable, or a field is: malformed JSON, not a JSON object, wrong type, too long, an unknown field, or not yours to set. When one field is at fault `detail.parameter` names it, and a roster item problem also carries `detail.index`. A refused `return_url` also carries `detail.reason`: `not_allowed` or `malformed`. |
| `invalid_email` | 400 | The address on a workspace claim is not one we can send to. |
| `invalid_key` | 401 | The `Authorization` header is missing, malformed, or carries a key we do not recognize. A configuration bug on your side; stop and check the key. |
| `key_revoked` | 403 | The key was valid and we turned it off. Not a configuration bug; email support for a replacement. |
| `partner_disabled` | 403 | Either the whole integration is paused on our side, or we hold no registry row for your slug at all. Your key is not the problem and a new one will not change the answer. Email support. |
| `not_found` | 404 | No such path on this API, or no such `inspection_id` or `import_id` in that workspace. Also what you get for a resource belonging to a different workspace; we do not distinguish the two. |
| `workspace_not_provisioned` | 404 | That workspace id is not provisioned for you. Create it with `POST /v1/workspaces`. |
| `task_not_found` | 404 | No recording link has been issued for that `task_id` in that workspace. Call `GET /v1/recording-link` first. |
| `property_not_found` | 404 | That `property_id` is not in the workspace's roster. Send a roster that contains it. We do not create properties from a link request. |
| `method_not_allowed` | 405 | The path exists; the verb does not. `detail.allowed_methods` lists the ones that do. |
| `claim_requires_admin` | 409 | `POST /v1/workspace-claims` on a workspace your key did not create, or one that already has people in it. Ask RapidEye to nominate the owner. |
| `workspace_already_claimed` | 409 | The workspace already has an owner. They manage their own team from Settings; nothing for you to do. |
| `report_not_ready` | 409 | The inspection exists but has no released report yet, so it has no findings. Wait for `inspection.completed`; do not treat this as a permanent failure. |
| `video_not_ready` | 409 | The inspection's video is not recorded yet, still being processed, or being recorded again. Call again after `inspection.recording_completed` or `inspection.completed`. Not a permanent failure. |
| `video_not_available` | 404 | The inspection has no video to download: it was a photo inspection, or its recording could not be processed. This answer rarely changes, so do not poll for it. |
| `roster_required` | 409 | No roster has been confirmed for this workspace yet, so no recording links can be issued. Send one to `POST /v1/roster-imports`. |
| `property_inactive` | 409 | The property is in the roster but was not in the most recent snapshot you sent, so it is marked inactive. Include it in your next import to bring it back. |
| `property_not_activated` | 409 | The property is in the roster but is not switched on for inspections. Properties switch on a few seconds after an import applies, so if the import has only just applied, wait and retry. Otherwise send the roster again with a new `Idempotency-Key` (or trigger a sync with `POST /v1/roster-syncs` if you host a feed). If this persists, email support. |
| `workspace_not_enabled` | 409 | The account exists and inspections are not switched on for it. The account switches on when a roster import applies, so send the roster again with a new `Idempotency-Key` (or trigger a sync with `POST /v1/roster-syncs` if you host a feed). If this persists, email support. |
| `task_property_conflict` | 409 | That `task_id` already has a link for a different property. A task is bound to one property for its whole life. Use a new `task_id`. |
| `idempotency_conflict` | 409 | That `Idempotency-Key` was already used for a different roster. Reuse a key only to retry the identical request. |
| `roster_revision_conflict` | 409 | The import was built on a roster revision that is no longer current, because another import landed first. Re-read `GET /v1/properties` and send a fresh snapshot with a new `Idempotency-Key`. Appears on the run, in `error.code`. |
| `roster_empty_not_allowed` | 409 | The import carries no properties. An empty roster retires every door, so it is never implicit: re-send with `allow_empty: true` and a new `Idempotency-Key`. Appears on the run, in `error.code`. |
| `roster_confirmation_required` | 409 | The import would deactivate more than half of the present properties. Re-send with `acknowledge_inactive_count` set to the count in `detail` and a new `Idempotency-Key`. Appears on the run, in `error.code`. |
| `roster_duplicate_property` | 409 | A `property_id` appeared twice in one roster. Appears on the run, in `error.code`, for a feed we pulled. A pushed body gets `400 invalid_request` with the repeated ids in `detail.duplicate_property_ids`. |
| `pull_not_configured` | 409 | This workspace pushes its roster; there is no feed for us to poll. |
| `matterport_import_in_progress` | 409 | A Matterport tour is still importing for this home: a different tour on `PUT .../matterport` (`detail.importing` names it), or any tour on `POST .../baseline-photos`, because a home runs one import at a time. `detail.import_id` names the running import. Wait for `setup.matterport.state` to leave `importing`, then send again. |
| `baseline_import_in_progress` | 409 | A baseline photo import is still running for this home, yours or one its managers started, so a new photo batch or a new tour waits: a home runs one import at a time. When the running import is yours, `detail.import_id` names it; one a manager started has no id you can read. Send again once `setup.baseline_photos.state` leaves `importing`. |
| `workspace_already_claimed` | 409 | An owner has already claimed this workspace. |
| `claim_requires_admin` | 409 | This workspace is not one you may nominate an owner for. Email support. |
| `payload_too_large` | 413 | The request body exceeds the endpoint's cap. `detail.limit_bytes` names it. |
| `too_many_requests` | 429 | Too many requests in a short window. Back off and retry; nothing is lost. Provisioning, roster imports, claims and manual syncs each have their own tighter bound. |
| `internal_error` | 500 | Something broke on our side. The request was not processed; retrying is safe. |
| `service_unavailable` | 503 | A dependency of ours is failing, including the one that verifies your key. **Not** a problem with the key; never roll a key in response to this. |

## Provisioning workspaces

A workspace is one of your customers. Each one maps to an account on our side
where that customer's properties, inspections and reports live, and a recording
link cannot be issued for a workspace that does not exist yet.

You provision them yourself; we are not in the loop.

### `POST /v1/workspaces`

Call it once, when you sign a customer. Idempotent create-or-return keyed on your
`workspace_id`: the first call creates the account and answers `201`, every later
call answers `200` with the **same** `company_id`, and concurrent calls for one
workspace all resolve to one account. There is no update or delete; a workspace
is never re-pointed by an API call.

| Field | Required | Notes |
|---|---|---|
| `workspace_id` | yes | Your account or workspace id. Opaque to us: surrounding whitespace is trimmed, and what remains is stored and matched literally, never split, case-folded or normalized. Max 200 characters. |
| `name` | yes | The customer's display name, as it should read on their reports. Max 200 characters. |

```bash
curl -sS -X POST https://api.rapideyeinspections.com/v1/workspaces \
  -H "Authorization: Bearer rapideye_pk_REPLACE_ME" \
  -H "Content-Type: application/json" \
  -d '{"workspace_id": "ws_123", "name": "Sunset Property Management"}'
```

```json
{
  "workspace_id": "ws_123",
  "name": "Sunset Property Management",
  "company_id": "7c3f1a90-2c44-4b6d-9e01-7a5c2f8e1d33",
  "status": "provisioned",
  "provisioned_at": "2026-08-13T14:02:11Z"
}
```

`company_id` is **ours**, the account your workspace resolves to. Store it if it
helps your support team; nothing else in this API requires you to send it back,
and it is never accepted in a request body.

Notes worth knowing before you build against it:

- **The partner is taken from your API key.** Sending `partner`, `partner_id` or
  `company_id` in the body is rejected with `400 invalid_request` rather than
  ignored, so a wrong assumption fails loudly on your first call instead of
  quietly on your customer's.
- **Workspace ids are scoped to you.** Another partner using the same string is a
  different workspace and a different account, and neither of you can see the
  other's.
- **A repeat call does not rename.** The first call names the account; later calls
  return the name we currently hold. If a customer renames, tell us. A rename can
  cross into reports and invoices, so it is not a thing a retry should do silently.
- **Provisioning is rate-limited** (creation only; repeat calls for an existing
  workspace are not). A `429` here means back off and retry, or email support if you
  are onboarding a batch.

### `GET /v1/workspaces/{workspace_id}`

Everything you need to know about one workspace: whether it is provisioned, how
current its roster is, whether we are polling a feed, whether inspections are
switched on, and whether an owner has claimed it. This is the endpoint your
onboarding flow polls.

```bash
curl -sS https://api.rapideyeinspections.com/v1/workspaces/ws_123 \
  -H "Authorization: Bearer rapideye_pk_REPLACE_ME"
```

```json
{
  "workspace_id": "ws_123",
  "name": "Sunset Property Management",
  "company_id": "7c3f1a90-2c44-4b6d-9e01-7a5c2f8e1d33",
  "status": "provisioned",
  "provisioned_at": "2026-08-13T14:02:11Z",
  "roster": {
    "revision": 4,
    "state": "fresh",
    "roster_confirmed_at": "2026-09-16T06:12:00Z",
    "observed_at": "2026-09-16T06:00:00Z",
    "last_source": "push",
    "present_count": 128,
    "inactive_count": 3,
    "unconfirmed_count": 0,
    "missing_address_count": 2,
    "billing_active_count": 121
  },
  "sync": { "mode": "push", "health": "not_configured" },
  "service": { "state": "enabled", "recording_enabled": true },
  "claim": { "state": "claimed" }
}
```

Four blocks, because there are four different questions and a different party
acts on each one.

| Block | Field | Meaning |
|---|---|---|
| `roster` | `revision` | Increments on every applied import. Also returned by `GET /v1/recording-link`. |
| `roster` | `state` | `unconfirmed` before your first import, then `fresh`, then `stale` once `observed_at` is more than 48 hours old. Warn only; a stale roster never blocks a recording. |
| `roster` | `observed_at` | The `observed_at` you sent on the latest applied import, which is when your snapshot described your inventory, not when we applied it. |
| `roster` | `last_source` | `push` or `pull`. |
| `roster` | `present_count` | Properties in the latest snapshot. |
| `roster` | `inactive_count` | Properties we have seen before that the latest snapshot left out. |
| `roster` | `unconfirmed_count` | Properties we hold that no validated roster has confirmed. Non-zero only during onboarding; it should reach zero after your first import. |
| `roster` | `missing_address_count` | Present properties that carry `address_unavailable_reason` instead of an address. |
| `roster` | `billing_active_count` | Properties switched on for inspections. Set by each applied import. |
| `sync` | `mode` | `push` if you send rosters, `pull` if we poll a feed you host. |
| `sync` | `health` | `not_configured`, `never_run`, `ok` or `failing`. |
| `service` | `state` | `pending_setup` until the first roster import applies, then `enabled` a few seconds later. |
| `service` | `recording_enabled` | Whether `GET /v1/recording-link` will issue links at all. |
| `claim` | `state` | `unclaimed`, `pending` (an invite is outstanding) or `claimed`. |

`404 workspace_not_provisioned` otherwise. The id is matched literally and has to
survive as one path segment, so percent-encode anything reserved (`/`, `?`, `#`).
A workspace belonging to another partner returns the same `404` as one that does
not exist anywhere.

`POST /v1/workspaces` returns the first five fields only. At the instant a
workspace is provisioned every count is zero and every state is its initial one,
so there is nothing for the four blocks to say.

### We do not match your workspace to an existing RapidEye account

A new `workspace_id` **always** gets a new, empty account, even when the property
manager behind it is already a RapidEye customer under their own contract. We do
not match on company name, on email domain, or on anything else.

This is a decision, not a gap. A duplicate account is something we merge in a few
minutes. A wrong match silently routes one operator's inspections, reports and
recording links into another operator's account, and every API call keeps
answering `200` while it happens. If you know a customer of yours is already with
us, tell us and we will link the two by hand.

### What a freshly provisioned account can do

Nothing yet, on purpose. Nobody can sign in against it, it has no email domains
that would let anyone attach to it, no notification destinations, no properties
and no active properties. It is a routing destination. Properties arrive when you
import a roster, and the account switches on for inspections when that import
applies; people arrive through a workspace claim.

## The roster

A roster is the **complete** list of properties in one workspace. It is the
central object in this integration, because it is what makes a recording link
safe to issue: a link is only ever issued for a property you have told us about.

Three properties of a roster, all of which follow from it being complete:

- **Omission means deactivation.** A property you have sent before and leave out
  of a later snapshot is marked `inactive`. Its history, its past inspections and
  its reports are all kept. Nothing is ever deleted.
- **An applied import activates what it lists as active.** Every property an
  applied import lists as `active` (the default when `status` is omitted) is
  switched on for inspections, and the account with it. One you send as
  `inactive`, or leave out, is
  deactivated, as above. The result is reported as
  `billing_active` and `roster.billing_active_count`.
- **A roster replaces, it does not merge.** There is no add-one or remove-one
  call, deliberately. Every partial-update API grows into a reconciliation
  problem where the two sides disagree and neither can say which is right.

### The roster item

The same shape whether you push it to us or host it on a feed we poll.

```json
{
  "property_id": "unit_42",
  "name": "Beach House Unit 1",
  "status": "active",
  "address": {
    "line1": "18 Ocean Drive",
    "line2": "Apt 3",
    "city": "Panama City Beach",
    "region": "FL",
    "postal_code": "32413",
    "country_code": "US"
  },
  "building_id": "bldg_7",
  "unit_label": "3B"
}
```

| Field | Required | Notes |
|---|---|---|
| `property_id` | yes | Your listing or property id. Opaque to us, matched literally, unique within the workspace, max 200 characters. |
| `name` | yes | Display name. This is what titles reports, so send the name your operator would recognize. Max 200 characters. |
| `status` | no | `active` or `inactive`. Defaults to `active`. Use `inactive` for a unit that is still yours and is out of service; leave it out of the snapshot entirely only when it has left the roster. |
| `address` | see below | Object with `line1`, `city`, `region`, `postal_code`, `country_code` all required, and `line2` optional. |
| `address_unavailable_reason` | see below | Free text, max 500 characters. Send this **instead of** `address` when you genuinely have no address. |
| `building_id` | no | Groups units in one building. Max 200 characters. |
| `unit_label` | no | The unit within the building. Max 100 characters. |

**Exactly one of `address` and `address_unavailable_reason`.** Sending both is
rejected, and so is sending neither. Addresses are required because they are what
make a report, a cleaner's route and an invoice line legible, and "we will add
them later" has not once happened on an integration. The escape hatch is real,
though: a property with `address_unavailable_reason` imports normally, stays in
every count, and shows as `address_status: "missing"` so both sides can see how
much of the roster is unaddressed.

Unknown fields are rejected rather than ignored. A typo like `unitLabel` that we
answered `200` to would be an integration that silently never set the field.

### `POST /v1/roster-imports?workspace_id=...`

Submit one complete snapshot. Answers `202` with a status resource; applying a
roster is not work to do inside a request you are holding open.

**`Idempotency-Key` is a required header.** An import deactivates every property
it omits, so a retry after a timeout is not a repeated read, it is a second
complete-snapshot write. Send a new value for every import attempt, and reuse
it verbatim only to retry that exact request after a timeout. A run that
reached `applied`, `rejected` or `failed` is final: re-sending with its key
returns that same run, so any further attempt, including an unchanged roster
after a `failed` run, needs a fresh key.

| Field | Required | Notes |
|---|---|---|
| `complete` | yes | Must be `true`. Your assertion that this array is the whole roster. |
| `observed_at` | yes | When your snapshot described your inventory. This is what freshness is measured from, not the time you sent it. |
| `total_count` | yes | Your own count of `properties`. We check it, which is what catches a truncated upload. |
| `properties` | yes | The array of roster items. Max 10,000 per import by default; ask us if you need more. |
| `allow_empty` | no | Required to be `true` for a roster with no properties. |
| `acknowledge_inactive_count` | no | Required when an import would deactivate more than half the present properties. Set it to the count we report. |

```bash
curl -sS -X POST "https://api.rapideyeinspections.com/v1/roster-imports?workspace_id=ws_123" \
  -H "Authorization: Bearer rapideye_pk_REPLACE_ME" \
  -H "Idempotency-Key: roster-2026-09-16T06:00Z" \
  -H "Content-Type: application/json" \
  -d '{
    "complete": true,
    "observed_at": "2026-09-16T06:00:00Z",
    "total_count": 1,
    "properties": [
      { "property_id": "unit_42", "name": "Beach House Unit 1",
        "address": { "line1": "18 Ocean Drive", "city": "Panama City Beach",
                     "region": "FL", "postal_code": "32413", "country_code": "US" } }
    ]
  }'
```

```json
{
  "import_id": "b2f4c8e1-9a35-4d77-8e10-2c6a4f9b1d55",
  "workspace_id": "ws_123",
  "status": "queued",
  "source": "push",
  "status_url": "https://api.rapideyeinspections.com/v1/roster-imports/b2f4c8e1-9a35-4d77-8e10-2c6a4f9b1d55?workspace_id=ws_123",
  "observed_at": "2026-09-16T06:00:00Z",
  "submitted_at": "2026-09-16T06:00:04Z",
  "counts": { "submitted": { "total": 1, "active": 1, "inactive": 0, "missing_address": 0 } },
  "base_revision": 3
}
```

What is checked before the `202`, and rejected with a `400` you can act on
immediately: the shape of every item, every length bound, the property count
against your cap, repeated `property_id` values, and `total_count` against the
array. A rejection names the offending item in `detail.index`.

What is decided afterwards, on the run: the two safeguards. An empty roster needs
`allow_empty`, and an import that would deactivate more than half of the present
properties needs `acknowledge_inactive_count` set to the number we compute. Both
come back as a `rejected` run carrying `roster_confirmation_required` and the
count, because they are properties of the difference between your snapshot and
the roster we hold, and that comparison is only meaningful under a lock.

Idempotency, precisely:

- **Same key, same roster** returns the same run at whatever status it has
  reached, including a final `failed`. Retrying after a timeout is free;
  retrying after a failure needs a new key.
- **Same key, different roster** is `409 idempotency_conflict`. One key must not
  stand for two different complete snapshots.
- Key matching is scoped to the workspace, and the roster comparison is over the
  normalized snapshot. Re-sending the same roster with your JSON keys in a
  different order, or your properties in a different order, is the same roster.

### `GET /v1/roster-imports/{import_id}?workspace_id=...`

The status, counts and diff of one import. `status` is one of `queued`,
`running`, `applied`, `rejected` or `failed`. The `snapshot` you sent is not
echoed back; read `GET /v1/properties` for what we ended up holding.

```json
{
  "import_id": "b2f4c8e1-9a35-4d77-8e10-2c6a4f9b1d55",
  "workspace_id": "ws_123",
  "status": "applied",
  "source": "push",
  "status_url": "https://api.rapideyeinspections.com/v1/roster-imports/b2f4c8e1-9a35-4d77-8e10-2c6a4f9b1d55?workspace_id=ws_123",
  "observed_at": "2026-09-16T06:00:00Z",
  "submitted_at": "2026-09-16T06:00:04Z",
  "applied_at": "2026-09-16T06:00:09Z",
  "base_revision": 3,
  "result_revision": 4,
  "counts": { "submitted": { "total": 1, "active": 1, "inactive": 0, "missing_address": 0 } },
  "diff": { "created": 0, "updated": 1, "deactivated": 0, "reactivated": 0 }
}
```

A `rejected` or `failed` run carries `error` with a `code` and a `detail`. The
codes you will see there are `roster_empty_not_allowed`,
`roster_confirmation_required`, `roster_revision_conflict` and
`roster_duplicate_property`, plus feed errors on a pull run. Branch on
`error.code`, never on the message.

An `import_id` from another workspace answers `404 not_found`, identical to an id
that does not exist.

### `GET /v1/properties?workspace_id=...`

The roster we currently hold. This is the reconciliation endpoint: your inventory
is the source of truth and ours is a copy, so the useful question is where the
two disagree, and that is only answerable if you can read our copy back.

| Parameter | Required | Notes |
|---|---|---|
| `workspace_id` | yes | Yours. Scopes the request to one customer. |
| `roster_status` | no | Filter to `present`, `inactive` or `unconfirmed`. |
| `limit` | no | Page size, 1 to 500. Defaults to 100. |
| `cursor` | no | The `next_cursor` from the previous page, sent back verbatim. Opaque. |

```json
{
  "workspace_id": "ws_123",
  "roster_revision": 4,
  "properties": [
    {
      "property_id": "unit_42",
      "rapideye_property_id": "1f0a9c22-6b1e-4a77-9b0f-2d2b0c5f7a10",
      "name": "Beach House Unit 1",
      "roster_status": "present",
      "address_status": "ok",
      "billing_active": true,
      "address": { "line1": "18 Ocean Drive", "city": "Panama City Beach",
                   "region": "FL", "postal_code": "32413", "country_code": "US" },
      "last_seen_at": "2026-09-16T06:00:09Z",
      "last_seen_revision": 4,
      "setup": {
        "checklist": { "items": 12 },
        "matterport": { "state": "imported", "url": "https://my.matterport.com/show/?m=SxQL3iGyoDo" },
        "baseline_photos": { "count": 18, "state": "imported" }
      }
    }
  ],
  "next_cursor": "dW5pdF80Mg=="
}
```

`property_id` here is **yours**, because this endpoint answers "what is in my
roster". Ours is `rapideye_property_id`, spelled out so neither has to be
inferred. `next_cursor` is omitted on the last page, so `while (next_cursor)`
terminates without an extra call.

`roster_status` is the roster fact and `billing_active` is whether the property
is switched on for inspections, which each applied import sets. A property that
is `present` and not billing-active is the state `property_not_activated`
reports. It can show for a few seconds just after an import applies, while
activation runs; wait and ask again first. If it is still there after that,
send the roster again with a new `Idempotency-Key` (or trigger a sync with
`POST /v1/roster-syncs` if you host a feed).

Pagination is keyset, over your own `property_id`. An import landing mid-walk
does not make rows appear or disappear from your pages.

### Hosting a feed instead of pushing

If you would rather we pull, host one HTTPS endpoint that returns the **same
body** as `POST /v1/roster-imports`, plus an optional `next_cursor`. We poll it
once a day.

| Requirement | Detail |
|---|---|
| Body | Identical to the push body: `complete`, `observed_at`, `total_count`, `properties`, and optionally `allow_empty` and `acknowledge_inactive_count` on the first page. |
| Pagination | Return `next_cursor` to continue. We re-request the same URL with `?cursor=<next_cursor>`. We never follow a URL you put in the body. |
| Consistency | Every page must carry the same `total_count` and `observed_at`, and the `properties` across all pages must sum to `total_count`. A repeated cursor fails the run. |
| Auth | `Authorization: Bearer <secret>`, from a secret we agree out of band. |
| Redirects | Not followed. A `3xx` fails the run. |
| Bounds | 200 pages, 20 MiB total, 20 seconds per request. |
| Empty | An intentionally empty roster must be `complete: true`, `total_count: 0`, `properties: []`. Any other empty `200` fails the run and changes nothing, because an empty response is far more often an outage than an emptied roster. |

A failed pull never resets freshness and never deactivates anything.

### `POST /v1/roster-syncs?workspace_id=...`

Poll the feed now instead of waiting for the daily run. `202` with a `sync_id`,
or `202` with `status: "in_progress"` when a pull is already running. A workspace
that pushes its roster answers `409 pull_not_configured`.

### Freshness

`roster.state` on the workspace read is `unconfirmed`, `fresh` or `stale`. Stale
means `observed_at` is more than 48 hours old.

**It is a warning, not a gate.** A stale roster still issues recording links,
because a roster we think is old is still the best roster we have and refusing a
customer's inspections over a late cron would be the worse failure. We watch it
on our side and will talk to you before it ever becomes a block.

## Setting up a home

Once a property is in the roster you can prepare it for inspections: give it the
checklist the cleaner works through, a Matterport tour, and baseline photos of
the home in good condition. All three are optional, and an inspection works
without any of them; each one makes the report sharper.

Every call here addresses a home by **your** `property_id`, the one you sent in
the roster, and takes `workspace_id` as a query parameter. Any roster state may
be set up, `inactive` and `unconfirmed` included, so you can prepare a home
before it goes live. An id that is not in that workspace's roster answers
`404 property_not_found`, and so does an id that belongs to a different
workspace. The id is matched literally and must survive as one path segment, so
percent-encode anything reserved.

Request bodies on these endpoints are limited to 376,224 bytes; a larger body
answers `413 payload_too_large` before it is read. That is a guard against a
mistaken bulk upload, separate from the field limits below. It sits well above
any realistic body at those limits, and both rules apply.

Progress for every home shows in the `setup` block on
`GET /v1/properties`:

```json
"setup": {
  "checklist": { "items": 12 },
  "matterport": { "state": "imported", "url": "https://my.matterport.com/show/?m=SxQL3iGyoDo" },
  "baseline_photos": { "count": 18, "state": "imported" }
}
```

| Field | Meaning |
|---|---|
| `checklist.items` | How many items the home's own **housekeeping** checklist has (the list a cleaner is served on a turnover). `0` means it has none of its own. The inspection and maintenance lists are not counted here; read them with `GET .../checklist?type=`. |
| `matterport.state` | `none`, `importing`, `imported` or `failed`, for the tour the home currently names. `url` is omitted when no tour is set. |
| `baseline_photos.count` | Every photo in the home's baseline, whatever added it: your imports, and photos the home's managers or we added (uploads, a Google Drive folder, their Airbnb listing, Breezeway listing photos). |
| `baseline_photos.state` | `importing` while a photo import is running, yours or one the home's managers started. Otherwise `imported` when the home has any baseline photos, `failed` when it has none and its most recent import in the last 30 days failed, and `none` otherwise. Read the import itself for which photos were refused. |

### `PUT /v1/properties/{property_id}/checklist?workspace_id=...`

Replace the home's checklist with the list you send. The cleaner sees the items
in your order. It is a full replace, not a merge, and it is idempotent: sending
the same list twice leaves the same checklist. It is safe after the home has
been recorded; items a cleaner already checked off stay on the reports that
recorded them.

| Field | Required | Notes |
|---|---|---|
| `items` | yes | Array of 1 to 50 items. To remove the checklist, use `DELETE`; an empty array is refused. |
| `items[].name` | yes | The step as the cleaner reads it on their phone, one step at a time. 1 to 120 characters; under 55 reads best. Name the area, then the visible results: `Kitchen: counters clear, sink empty, floor swept`. A dash used as punctuation (an em dash, or an en dash with a space beside it) is refused; use a colon. |
| `items[].notes` | no | A short label shown under the step on one truncated line, such as the floor or room. Not a place for instructions. Up to 500 characters. |

Unknown fields, at the top level or on an item, are rejected with
`400 invalid_request`, and `detail` names the field and the item `index`.

**A home has three checklists, and `?type=` picks which one you are writing.**
The values are `housekeeping` (the default when you omit it), `inspection` and
`maintenance`. Each is a separate list: writing one never disturbs the other
two, and clearing one leaves the others alone. Anything outside those three is
`400 invalid_request` with `detail.parameter` set to `type`.

Housekeeping is the list a cleaner is served during a turnover, so it is the
one to use unless you have a reason not to. The response echoes `type` back
even when you left it out, so a stored response always says which list it
describes.

Every item comes back with an **`id`**, which is ours and is how per-item
results are reported (see *What we send back to you*). One thing to know about
it: a replace retires the old items and creates new ones, so an `id` is stable
between writes and not across them. That is deliberate, because a result should
point at the version of the item that was actually checked. Read the ids back
after each `PUT` rather than caching them across an edit.

```bash
curl -sS -X PUT "https://api.rapideyeinspections.com/v1/properties/unit_42/checklist?workspace_id=ws_123" \
  -H "Authorization: Bearer rapideye_pk_REPLACE_ME" \
  -H "Content-Type: application/json" \
  -d '{
    "items": [
      { "name": "Bedrooms: beds made, linens fresh, floors clear", "notes": "Upstairs" },
      { "name": "Hot tub: cover on and latched" }
    ]
  }'
```

```json
{
  "workspace_id": "ws_123",
  "property_id": "unit_42",
  "type": "housekeeping",
  "items": [
    { "id": "c1f0a2e4-...", "name": "Bedrooms: beds made, linens fresh, floors clear", "notes": "Upstairs" },
    { "id": "9b73d5a1-...", "name": "Hot tub: cover on and latched" }
  ],
  "updated_at": "2026-09-19T14:02:11Z"
}
```

### `GET /v1/properties/{property_id}/checklist?workspace_id=...`

The home's own checklist, in order. `items` is an empty array when the home
has none of its own, and `updated_at` is then omitted; a company-wide checklist
set up with us may still apply to that home.

`?type=` reads one of the three lists, same values and same default as the
`PUT`. Read the item `id`s back from here after any edit: they are what
per-item results are keyed on, and a replace issues new ones.

```bash
curl -sS "https://api.rapideyeinspections.com/v1/properties/unit_42/checklist?workspace_id=ws_123&type=maintenance" \
  -H "Authorization: Bearer rapideye_pk_REPLACE_ME"
```

```json
{
  "workspace_id": "ws_123",
  "property_id": "unit_42",
  "type": "maintenance",
  "items": [
    { "id": "c1f0a2e4-...", "name": "Bedrooms: beds made, linens fresh, floors clear", "notes": "Upstairs" }
  ],
  "updated_at": "2026-09-19T14:02:11Z"
}
```

### `DELETE /v1/properties/{property_id}/checklist?workspace_id=...`

Remove one of the home's own checklists. Idempotent: removing a checklist that
is not there is a `200`. If the customer has a company-wide checklist of that
type on our side, the home goes back to using it. `?type=` picks which list to
clear and leaves the other two alone.

```bash
curl -sS -X DELETE "https://api.rapideyeinspections.com/v1/properties/unit_42/checklist?workspace_id=ws_123" \
  -H "Authorization: Bearer rapideye_pk_REPLACE_ME"
```

```json
{ "workspace_id": "ws_123", "property_id": "unit_42", "type": "housekeeping", "items": [] }
```

### `PUT /v1/properties/{property_id}/matterport?workspace_id=...`

Set the home's Matterport tour and import it as baseline imagery. Answers `202`
with `state: "importing"`; watch `setup.matterport.state` on
`GET /v1/properties` for `imported` or `failed`.

| Field | Required | Notes |
|---|---|---|
| `url` | yes | A `https://my.matterport.com/show/?m=<model id>` share link (other query parameters are fine) or a `https://discover.matterport.com/space/<model id>` link. We store it as `https://my.matterport.com/show/?m=<model id>`. |

Sending a tour again is safe. The same tour that is already imported answers
`200` with `state: "imported"` and imports nothing; the same tour that is still
importing answers `202` with the import already running. A different tour while
one is importing is `409 matterport_import_in_progress`, and `detail.importing` names
the tour that is running; wait for the first to
finish. A tour whose import failed is imported again. A different tour while one
is already imported replaces it: the home keeps using the imported tour until
the new one finishes importing, and if the new import fails the old tour stays
in use and `state` reads `failed` for the tour you sent.

```bash
curl -sS -X PUT "https://api.rapideyeinspections.com/v1/properties/unit_42/matterport?workspace_id=ws_123" \
  -H "Authorization: Bearer rapideye_pk_REPLACE_ME" \
  -H "Content-Type: application/json" \
  -d '{ "url": "https://my.matterport.com/show/?m=SxQL3iGyoDo&play=1" }'
```

```json
{
  "workspace_id": "ws_123",
  "property_id": "unit_42",
  "matterport": { "url": "https://my.matterport.com/show/?m=SxQL3iGyoDo", "state": "importing" }
}
```

### `DELETE /v1/properties/{property_id}/matterport?workspace_id=...`

Remove the home's tour. Idempotent. Its imported imagery is deleted and stops
being used from the next inspection, and an import that is running is stopped.
Nothing else in the home's baseline (photos, the baseline video) is affected.

```bash
curl -sS -X DELETE "https://api.rapideyeinspections.com/v1/properties/unit_42/matterport?workspace_id=ws_123" \
  -H "Authorization: Bearer rapideye_pk_REPLACE_ME"
```

```json
{ "workspace_id": "ws_123", "property_id": "unit_42", "matterport": { "state": "none" } }
```

### `POST /v1/properties/{property_id}/baseline-photos?workspace_id=...`

Import baseline photos of the home, in good condition, from URLs. Answers `202`
with an `import_id`; the photos are fetched in the background. Photos add to the
home's baseline, they never replace it, and sending a photo we already hold
adds nothing, whatever URL it arrives from. One import runs per home at a
time: a second batch while one is `importing` is
`409 baseline_import_in_progress`. Baseline imports (photos and tours, from
every workspace) also queue behind each other on our side, so at a busy moment
an import can stay `importing` for some minutes before its first photo is
fetched. Poll the import; do not time it out and send it again.

| Field | Required | Notes |
|---|---|---|
| `photos` | yes | Array of 1 to 25 objects. A repeated photo counts toward the 25 and is fetched, is stored once, and is reported in the import's `duplicates` count. |
| `photos[].url` | yes | An `https` URL on the default port, up to 2,048 characters, with no credentials in it. |

What we fetch, and what we refuse per photo rather than for the whole batch:

- **Public hosts only.** A host that resolves to a private, loopback,
  link-local or otherwise non-public address is refused, and so is a redirect
  to one. We follow at most three redirects, each checked the same way.
- **JPEG, PNG or WebP, up to 15 MB.** The `Content-Type` must say so and the
  bytes must match it.
- **20 seconds per photo.** A slower server fails that photo.
- **Nothing of yours or ours is sent.** No cookies and no authorization
  header, so the URL has to be fetchable on its own; a signed URL that lives
  for a few minutes is fine.

```bash
curl -sS -X POST "https://api.rapideyeinspections.com/v1/properties/unit_42/baseline-photos?workspace_id=ws_123" \
  -H "Authorization: Bearer rapideye_pk_REPLACE_ME" \
  -H "Content-Type: application/json" \
  -d '{
    "photos": [
      { "url": "https://photos.example.com/unit_42/living-room.jpg" },
      { "url": "https://photos.example.com/unit_42/kitchen.jpg" }
    ]
  }'
```

```json
{
  "workspace_id": "ws_123",
  "property_id": "unit_42",
  "import_id": "5d0c7a4e-3b1f-4f7e-9c2a-8e6b1d4f0a37",
  "state": "importing",
  "accepted": 2
}
```

### `GET /v1/properties/{property_id}/baseline-photos/imports/{import_id}?workspace_id=...`

One photo import: its `state` (`importing`, `imported` or `failed`), how many
photos were `stored`, how many were `duplicates` (a photo the home already
holds, from this request or from anywhere else, or one removed from the home
while the import ran), and every photo we refused, with its position in your
request, its URL and a `reason`. `stored`, `duplicates` and the refused photos
add up to the number you sent. The import is `failed` when every photo was
refused, or when it failed before it could report on any photo. The first
carries no `error`: every refused photo is already in `failed` with its own
`reason`. The second carries a short `error`, and sending the same photos again
is safe.

| `reason` | Meaning |
|---|---|
| `invalid_url` | The URL, or a redirect it sent us to, is not one we fetch. |
| `dns_failed` | The host did not resolve. |
| `private_address` | The host resolves to a non-public address. |
| `connect_failed` | We could not connect or the connection broke. |
| `too_many_redirects` | More than three redirects. |
| `http_status` | The server answered something other than `200`. |
| `unsupported_content_type` | Not JPEG, PNG or WebP. |
| `content_mismatch` | The bytes are not a usable image of the type the server declared: another type, truncated or corrupt, or over 100 megapixels. |
| `too_large` | Over 15 MB. |
| `timeout` | The photo took longer than 20 seconds. |
| `upload_failed` | We fetched it and could not store it. Sending it again is safe. |
| `fetch_failed` | Something unexpected went wrong on our side with this photo. Sending it again is safe. |

An `import_id` from another home or workspace answers `404 not_found`,
identical to one that does not exist.

```bash
curl -sS "https://api.rapideyeinspections.com/v1/properties/unit_42/baseline-photos/imports/5d0c7a4e-3b1f-4f7e-9c2a-8e6b1d4f0a37?workspace_id=ws_123" \
  -H "Authorization: Bearer rapideye_pk_REPLACE_ME"
```

```json
{
  "workspace_id": "ws_123",
  "property_id": "unit_42",
  "import_id": "5d0c7a4e-3b1f-4f7e-9c2a-8e6b1d4f0a37",
  "state": "imported",
  "stored": 1,
  "duplicates": 0,
  "failed": [
    { "index": 1, "url": "https://photos.example.com/unit_42/kitchen.jpg", "reason": "http_status" }
  ]
}
```

## Requesting from us

### `GET /v1/recording-link`

Call it when a task is created, or whenever you need the link. Idempotent for a
task that passes the checks: the same `task_id` returns the same URL, so it is
safe to call on create, on update, and on any render path.

**This endpoint does not create properties.** The `property_id` must already be
`present` in the workspace's roster; the import that put it there also
activated it, a few seconds after the import applied.

| Parameter | Required | Notes |
|---|---|---|
| `workspace_id` | yes | Your account or workspace id; identifies the customer |
| `task_id` | yes | Your task id; the idempotency key |
| `property_id` | yes | Your listing or property id, as it appears in the roster |
| `task_name` | no | Free text |
| `scheduled_date` | no | `YYYY-MM-DD` |
| `return_url` | no | Where to send the cleaner when the walkthrough is saved, for example `conduit://rapideye/complete`. Must be on your allowlist. See *Sending the cleaner back to your app* below |

```bash
curl -sS -G https://api.rapideyeinspections.com/v1/recording-link \
  -H "Authorization: Bearer rapideye_pk_REPLACE_ME" \
  --data-urlencode "workspace_id=ws_123" \
  --data-urlencode "task_id=task_abc" \
  --data-urlencode "property_id=unit_42"
```

```json
{
  "recording_url": "https://app.rapideyeinspections.com/record/...",
  "task_id": "task_abc",
  "property_id": "1f0a9c22-6b1e-4a77-9b0f-2d2b0c5f7a10",
  "external_property_id": "unit_42",
  "roster_revision": 4
}
```

Treat `recording_url` as opaque: store and render it exactly as returned. Save it
on your task and render it as a button the cleaner taps.

Note the two property ids. `property_id` in the response is **ours**, the uuid
inside `recording_url`. `external_property_id` echoes **yours**, so a handler can
match the response to the request without inferring which is which.

#### Sending the cleaner back to your app (`return_url`)

By default the cleaner's last screen says the recording is saved and that they
can close the page. Pass `return_url` and that screen sends them back to you
instead:

```bash
curl -sS -G https://api.rapideyeinspections.com/v1/recording-link \
  -H "Authorization: Bearer rapideye_pk_REPLACE_ME" \
  --data-urlencode "workspace_id=ws_123" \
  --data-urlencode "task_id=task_abc" \
  --data-urlencode "property_id=unit_42" \
  --data-urlencode "return_url=conduit://rapideye/complete"
```

The response echoes it as `"return_url": "conduit://rapideye/complete"`.

**The allowlist.** We only send a cleaner to a URL your integration is set up
for, so the recording page can never be used to bounce someone to an arbitrary
site. Each entry is a scheme for your app (`conduit://` allows any URL under
that scheme), or a web origin with an exact host (`https://app.example.com`;
another host, a subdomain or a different port does not match). Email
support@rapideyeinspections.com to add one. Anything else is
`400 invalid_request` with `detail.parameter` set to `return_url` and
`detail.reason` set to `not_allowed` (not on your list) or `malformed` (not an
absolute `scheme://host/path` URL of at most 2,048 characters; it contains
spaces, control characters, a backslash or a `user@` part; or its host or port
is one a browser cannot open, such as a port above 65535, a port with no host,
or a host with characters other than letters, digits and `. _ ~ -`). This check runs
with the other parameter checks, before the refusals below. If we cannot read
your allowlist at that moment the answer is `503 service_unavailable`; retrying
is safe.

**It is stored per task.** A request that carries `return_url` sets it for that
`task_id`. A request without it leaves the stored value as it is, so a render
path that does not know about it never erases it. Send a new value to change it.

**When the redirect fires.** The cleaner goes through the whole finish flow
exactly as they do today. Then, in this order:

1. Every part of the video has uploaded from the phone.
2. While we seal the walkthrough on our side, the screen reads "Finishing your
   upload. Keep this page open." Sealing takes about 30 seconds after the
   cleaner taps Done (90% within 70 seconds, almost all within 2.5 minutes).
3. Once it is sealed, the cleaner sees the usual "Recording uploaded" screen for
   3 seconds, with a **Return to {your name}** button where the "you can close
   this page" line usually is.
4. The page navigates to your URL with two query parameters added (any query or
   fragment of your own is kept):

   ```
   conduit://rapideye/complete?inspection_id=6a1d2c80-3f0e-4b3a-9d61-0c8f1b7e2a55&status=submitted
   ```

   | Parameter | Value |
   |---|---|
   | `inspection_id` | Our inspection id, the same one the webhooks and `GET /v1/inspections` carry |
   | `status` | Always `submitted`: the walkthrough is safely with us. Results follow on the usual webhooks |

We never send the cleaner away before the walkthrough is sealed, because
closing the browser while the video is still uploading loses footage. On a
poor connection the phone's last request can reach us late or not at all; the
screen then keeps waiting, up to the 5-minute limit. If it does not seal within
5 minutes, the cleaner gets today's screen and its "you
can close this page" message, with no redirect; the inspection still finishes on
our side, and your webhooks arrive as usual. If the upload itself fails, the
cleaner sees the same error and retry screens as today, and nothing redirects.

**How browsers handle the navigation.** A redirect to an app scheme 30 seconds
or more after the cleaner's last tap is treated differently by each way of
opening the link. The **Return to {your name}** button is a plain link: a tap on
it counts as the cleaner's own action, and that works where the automatic
navigation is blocked.

| Opened in | Automatic redirect | What to do |
|---|---|---|
| Your own in-app web view (`WKWebView`, Android `WebView`) | Your app sees it and decides | Intercept the navigation (`decidePolicyFor` on iOS, `shouldOverrideUrlLoading` on Android), read the two parameters and close the web view. This is the most dependable option |
| iOS `SFSafariViewController` | Often works. iOS can drop a scripted custom-scheme navigation that comes long after the last tap | Handle the URL in your app or scene delegate and dismiss the view controller. The button covers the dropped case |
| Android Chrome Custom Tabs | Usually blocked. Chrome does not launch another app without a recent user action, and may show a "leave Chrome?" message instead | Expect the cleaner to tap the button. Handle the URL with an intent filter for your scheme |
| A full browser (Safari, Chrome) | Asks the cleaner, or does nothing | The cleaner taps the button, then confirms the browser's "Open in app" prompt |

#### What it refuses, and in what order

The checks run outermost first, so you are always told the largest thing that is
wrong. Each one has a different remedy, which is why they are separate codes.

| Order | Check | Refusal | Who acts |
|---|---|---|---|
| 1 | The workspace is provisioned | `404 workspace_not_provisioned` | You: `POST /v1/workspaces` |
| 2 | Your integration is registered and live | `403 partner_disabled` | Us: email support |
| 3 | A roster has been confirmed | `409 roster_required` | You: `POST /v1/roster-imports` |
| 4 | The property is in the roster | `404 property_not_found` | You: send a roster containing it |
| 5 | The property is still present | `409 property_inactive` | You: include it in your next roster |
| 6 | The property is activated | `409 property_not_activated` | You: if an import has only just applied, wait and retry. Otherwise re-send the roster with a new `Idempotency-Key`, or trigger a sync. Us, if it persists |
| 7 | The account is in service | `409 workspace_not_enabled` | You: re-send the roster with a new `Idempotency-Key`, or trigger a sync. Us, if it persists |
| 8 | The task is not bound elsewhere | `409 task_property_conflict` | You: use a new `task_id` |

`400 missing_parameter` comes before all of them, with `detail.parameter` naming
the parameter, for example `"detail": { "parameter": "workspace_id" }`. So does a
refused `return_url` (`400 invalid_request`, see *Sending the cleaner back to
your app*).

A task is bound to one property for its whole life. The recording, the report and
your webhook all resolve through that binding, so re-pointing a `task_id` at a
different property would attach a walkthrough to the wrong unit.

### `GET /v1/inspections?workspace_id=...&task_id=...`

Status poll; a fallback for the webhook and a way to backfill after downtime. The
webhook is the primary signal. Poll no more than once per minute per task.

An inspection has two parts that finish at different times, and the response
describes each as a block with its own status:

- **`checklist`**: the per-item results. Final minutes after the walkthrough.
  See *Per-item checklist results* below.
- **`report`**: the findings report. Ready hours later.

`status` is the inspection's overall lifecycle: `pending`, `recording`,
`processing`, `completed` or `failed`. `completed` means the report is ready.

While the report is being produced:

```json
{
  "status": "processing",
  "inspection_id": "3b7f1a90-2c44-4b6d-9e01-7a5c2f8e1d33",
  "report": { "status": "processing", "stage": "reviewing" },
  "media_expires_in": 900,
  "checklist": {
    "status": "complete",
    "counts": { "verified": 9, "missed": 2, "explained": 1, "not_checked": 0, "pending": 0 },
    "items": [
      { "id": "c1f0a2e4-...", "name": "Bedrooms: beds made, linens fresh", "state": "verified",
        "timestamp": 84.2, "image_url": "https://...signed..." }
    ]
  }
}
```

Once it is ready:

```json
{
  "status": "completed",
  "inspection_id": "3b7f1a90-2c44-4b6d-9e01-7a5c2f8e1d33",
  "report_url": "https://app.rapideyeinspections.com/reports/3b7f1a90-2c44-4b6d-9e01-7a5c2f8e1d33",
  "completed_at": "2026-08-01T17:04:33Z",
  "report": {
    "status": "ready",
    "report_url": "https://app.rapideyeinspections.com/reports/3b7f1a90-2c44-4b6d-9e01-7a5c2f8e1d33"
  },
  "media_expires_in": 900,
  "checklist": {
    "status": "complete",
    "counts": { "verified": 9, "missed": 2, "explained": 1, "not_checked": 0, "pending": 0 },
    "items": [
      { "id": "c1f0a2e4-...", "name": "Bedrooms: beds made, linens fresh", "state": "verified",
        "timestamp": 84.2, "image_url": "https://...signed..." }
    ]
  }
}
```

`media_expires_in` comes with the `checklist` block: the lifetime of its
`image_url` links, as on the findings response. Every read signs the links
afresh, so this is where you get a new link for a picture whose 15 minutes have
run out.

The `report` block:

| Field | Present | Values |
|---|---|---|
| `status` | always | `processing` or `ready` |
| `stage` | while `processing` | `analyzing` (our models are working through the walkthrough) or `reviewing` (the findings are with our reviewers) |
| `report_url` | when `ready` | The report link, the same URL as everywhere else |

`report` appears on `processing` and `completed`. It is omitted on `pending`
and `recording`, where no report is under way yet, and on `failed`, where none
is coming. `checklist` appears as soon as the walkthrough has any results, on
any status, and is omitted for a home with no checklist.

`inspection_id` appears as soon as the cleaner opens the recording link.
`report_url` and `completed_at` at the top level appear only on `completed`. They predate the
`report` block and stay, so an integration built on them keeps working; new
code should read `report`. Unknown `task_id` returns
`404 task_not_found`. Treat an unrecognized status, block status or stage as
in-flight so we can add states without breaking you.

`inspection_id` is ours, and this is how you get it if you missed the webhook.
It is what the findings endpoint below takes.

### `GET /v1/inspections/{inspection_id}/findings?workspace_id=...`

The findings behind a completed inspection, if you want the detail rather than
the report link. Call it when `inspection.completed` arrives; the webhook carries
both ids you need.

| Parameter | Required | Notes |
|---|---|---|
| `inspection_id` | yes | Path segment. **Ours**, from the webhook or from `GET /v1/inspections`. |
| `workspace_id` | yes | Query. Yours. Scopes the request to one of your customers. |

```bash
curl -sS -G "https://api.rapideyeinspections.com/v1/inspections/3b7f1a90-2c44-4b6d-9e01-7a5c2f8e1d33/findings" \
  -H "Authorization: Bearer rapideye_pk_REPLACE_ME" \
  --data-urlencode "workspace_id=ws_123"
```

```json
{
  "inspection_id": "3b7f1a90-2c44-4b6d-9e01-7a5c2f8e1d33",
  "report_url": "https://app.rapideyeinspections.com/reports/3b7f1a90-2c44-4b6d-9e01-7a5c2f8e1d33",
  "media_expires_in": 900,
  "checklist": {
    "status": "complete",
    "counts": { "verified": 1, "missed": 0, "explained": 0, "not_checked": 0, "pending": 0 },
    "items": [
      { "id": "c1f0a2e4-...", "name": "Bedrooms: beds made, linens fresh", "state": "verified" }
    ]
  },
  "findings": [
    {
      "id": "8c2d1f60-4a11-4de2-9b73-0e5a6c1d2f88",
      "description": "Burn mark on the arm of the sofa, roughly 3cm across.",
      "room": "Living Room",
      "severity": "MAJOR",
      "category": "damage",
      "video_timestamp_seconds": 128.4,
      "evidence": {
        "before": [ { "url": "https://...signed..." } ],
        "after": [ { "url": "https://...signed..." } ]
      }
    }
  ]
}
```

| Field | Type | Always present | Notes |
|---|---|---|---|
| `id` | string (uuid) | yes | Ours, and stable. Use it to deduplicate if you call twice. |
| `description` | string | yes | One sentence, written for a work order. |
| `room` | string | yes | The area of the property, e.g. `Kitchen`, `Bedroom 2`. Route the follow-up task on this. |
| `severity` | string | yes | Exactly one of `MINOR`, `MODERATE`, `MAJOR`. |
| `category` | string | yes | Exactly one of `damage`, `cleaning`, `lost_item`. |
| `evidence` | object | yes | `{ "before": [...], "after": [...] }`. Both keys always present; either may be `[]`. |
| `video_timestamp_seconds` | number | no | Where in the walkthrough it was seen. Present on video-sourced findings only. |

`severity` and `category` are **closed sets**: we will not send a value outside
the three listed, so it is safe to branch on them exhaustively.

`evidence` has exactly two keys, `before` and `after`, each an array of media
references. `after` is the state we found, what the finding is about. `before`
is the same area on the previous visit, which is what makes a new problem legible
as new; it is empty on a property's first inspection and anywhere we had no
baseline, which is about a quarter of findings.

**`evidence` is the one place we send an empty value rather than omitting the
key**, and it is deliberate. Elsewhere an optional field is **omitted entirely**
when it does not apply, never `null` or `""`, because absence is the natural
way to say "not applicable" about a single value, and testing for the key is the
natural way to read it. For a list the useful guarantee is the other one: that
iterating is always safe. So `evidence`, `evidence.before` and `evidence.after`
are always there, and you can write `for image in finding.evidence.before`
without a guard on any finding we will ever send you. Both lists are empty on a
finding whose images we could not produce.

Every other optional field follows the omit rule, same as the error envelope.
Test for the key.

There is **no pagination**. One call returns every finding for the inspection in
one response, capped at 5000 findings (no real inspection approaches it). The
order is stable between calls but carries no meaning; sort by `severity` or
`room` if you need one.

**`workspace_id` is a boundary, not a hint.** Your key spans every workspace we
have provisioned for you, so this parameter is what scopes a request to one of
your customers. An `inspection_id` belonging to a different workspace answers
`404 not_found`, identical to an id that does not exist.

**Media URLs are short-lived and are minted per request.** A media reference is
`{ "url": "..." }`, a signed URL good for `media_expires_in` seconds from the
moment we answered, and no longer. **Fetch the bytes when the webhook arrives; do
not store the URL.** A stored URL is dead long before the task it is attached to
is, and re-reading this endpoint is the supported way to get fresh ones. There is
no permanent image URL to ask us for; the images live in a private bucket, which
is the point. It is an image: fetch it with a plain `GET` and no auth header, and
read the format from the response's `Content-Type` rather than the URL. Do not
assume a particular file extension.

**Only released reports.** Until human review is finished the endpoint answers
`409 report_not_ready`, which is a "not yet", not a failure. The inspection is
fine and `inspection.completed` is coming. We never expose findings that have not
been through review, so a `409` is also the correct answer for an inspection
whose analysis is still running. An unknown or out-of-workspace `inspection_id`
is `404 not_found`.

### `GET /v1/inspections/{inspection_id}/video?workspace_id=...`

A download link for the inspection's walkthrough video, so you can keep your own
copy. It is the same video the report plays, one per inspection. Items a cleaner
goes back to record from the checklist are already inside it.

| Parameter | Required | Notes |
|---|---|---|
| `inspection_id` | yes | Path segment. **Ours**, from the webhook or from `GET /v1/inspections`. |
| `workspace_id` | yes | Query. Yours. Scopes the request to one of your customers, exactly as on the findings endpoint. |

```bash
curl -sS -G "https://api.rapideyeinspections.com/v1/inspections/3b7f1a90-2c44-4b6d-9e01-7a5c2f8e1d33/video" \
  -H "Authorization: Bearer rapideye_pk_REPLACE_ME" \
  --data-urlencode "workspace_id=ws_123"
```

```json
{
  "inspection_id": "3b7f1a90-2c44-4b6d-9e01-7a5c2f8e1d33",
  "download_url": "https://...signed...",
  "expires_at": "2026-10-05T17:19:33Z",
  "media_expires_in": 900,
  "content_type": "video/mp4",
  "duration_seconds": 313,
  "size_bytes": 341087869,
  "recorded_at": "2026-10-05T17:01:12+00:00"
}
```

| Field | Type | Always present | Notes |
|---|---|---|---|
| `inspection_id` | string (uuid) | yes | Echoes the path. |
| `download_url` | string | yes | A signed link to the video file. Fetch it with a plain `GET` and no auth header. |
| `expires_at` | string | yes | When `download_url` stops working, ISO 8601 UTC. |
| `media_expires_in` | number | yes | The same lifetime in seconds from our answer, as on the findings response. |
| `content_type` | string | no | The file's media type. Almost always `video/mp4`; a few early recordings are `video/webm` or `video/quicktime`. |
| `duration_seconds` | number | no | Length of the video. Omitted when we did not measure it. |
| `size_bytes` | number | no | Size of the file. Omitted when we did not record it. |
| `recorded_at` | string | no | When the walkthrough was recorded. |

**Each call returns a fresh link** good for `media_expires_in` seconds, the same
15 minutes as finding images. Start the download within that window. **Do not store
`download_url`.** Store the file, or store this endpoint's URL together with
the `workspace_id`, which never expire: the `inspection.completed` webhook
carries the URL as `video_url` next to `workspace_id`, and calling it again is
the supported way to get a new link. Read the format from
`content_type` or the download's `Content-Type` header rather than from the URL.

**The video is available before the report.** The report waits for review; the
video does not, because review never changes it. It is ready by the time
`inspection.recording_completed` arrives, so you can fetch it from that event
onward.

| Answer | Meaning | What to do |
|---|---|---|
| `200` | The video is ready. | Download it from `download_url`. |
| `409 video_not_ready` | Not recorded yet, still being processed, or being recorded again. | Wait for `inspection.recording_completed` or `inspection.completed`, then call again. |
| `404 video_not_available` | A photo inspection, or a recording that could not be processed. | Stop polling. There is no video for this inspection. |
| `404 not_found` | No such inspection in that workspace, including one that belongs to another workspace. | Check the ids from the webhook. |
| `503 service_unavailable` | We could not create a link just now. | Retry; nothing is wrong with your request. |

### Per-item checklist results

When a home has a checklist, we report what the walkthrough showed for **each
item**, not just an outcome for the whole clean. The results do not wait for
the report: each verdict lands about 20 seconds after the cleaner checks the
item off, almost all of them while the walk is still going, so the block is
available long before review finishes.

```json
{
  "checklist": {
    "status": "complete",
    "counts": { "verified": 9, "missed": 2, "explained": 1, "not_checked": 0, "pending": 0 },
    "items": [
      {
        "id": "c1f0a2e4-...",
        "name": "Bedrooms: beds made, linens fresh",
        "state": "verified",
        "timestamp": 84.2,
        "image_url": "https://...signed..."
      },
      {
        "id": "9b73d5a1-...",
        "name": "Hot tub: cover on and latched",
        "state": "missed",
        "timestamp": 131.7,
        "image_url": "https://...signed..."
      },
      {
        "id": "4d20bb77-...",
        "name": "Patio: furniture straightened",
        "state": "explained",
        "reason": "Patio furniture is stacked in the shed for the winter",
        "timestamp": 201.0
      }
    ]
  }
}
```

The same block, with the same shape, rides on every surface that describes an
inspection:

| Where | What it holds |
|---|---|
| the `inspection.recording_completed` webhook | every item final, `complete`; or, if a result is still landing 2 minutes after the walkthrough is sealed, the results as they stand then, `in_progress` |
| the `inspection.completed` webhook | the results at the time the report was released |
| `GET /v1/inspections` | the results now, on every status |
| `GET /v1/inspections/{inspection_id}/findings` | the results now |

`status` is `in_progress` until the walkthrough's recording has finished
processing **and** every item has its final state, then `complete`. There is no
timeout: `complete` means every item is final. An item whose footage never
reached us (the phone lost it, or it was checked off after recording stopped)
is closed as `not_checked` within about 15 minutes of the walk ending, so a
walkthrough does not stay `in_progress` waiting for it.

Four final states, exactly the chips the RapidEye report shows, plus `pending`
while the walk is still being checked:

| State | Means | Picture |
|---|---|---|
| `verified` | The walkthrough showed the item done. | Almost always (see below) |
| `missed` | The walkthrough did not confirm the item: it showed it not done, never showed it clearly, or the cleaner finished without re-filming it ("Finish anyway"). | Almost always (see below); a "Finish anyway" item shows the picture of the check it skipped, when that check had one |
| `explained` | The cleaner explained why the camera missed an item they did. Carries `reason`, their words. | When the walkthrough had one for that moment |
| `not_checked` | We could not check the item: our check failed, or the footage of that moment never reached us. Not a result about the cleaner's work; do not count it as missed. | Only when our check got as far as the footage of that moment |
| `pending` | The verdict is still on its way. Only seen while `status` is `in_progress`. | Never |

**`pending` is not a result.** Show it as "checking" and read
`GET /v1/inspections?workspace_id=...&task_id=...` again, once a minute is enough, until `status` is `complete`;
`inspection.completed` carries the final results too. A pending item becomes
one of the other four.

**A complete result can still change, in two rare ways**, and the API shows the
new state from then on while a webhook already delivered is not re-sent:

- the cleaner explains an item after the walk (a `missed` becoming `explained`);
- an item that was `not_checked` because our model was over capacity is re-run once
  the model answers again, for up to 24 hours. While it re-runs the item reads
  `pending` and `status` reads `in_progress`; then it settles on its result and
  `status` returns to `complete`.

If you store results, read the block again when `inspection.completed` arrives:
it carries the latest.

`counts` names all five states every time, with zeros, so a badge never has to
test for a key.

**A picture of each step.** A `verified`, `missed`, `explained` or `not_checked` item carries:

| Field | Type | Notes |
|---|---|---|
| `image_url` | string | A still from the walkthrough at the moment the item was checked off: the same picture the RapidEye report shows beside it. A signed link, good for 15 minutes from our answer (`media_expires_in`), exactly like a finding image. Present whenever the picture exists (see the table above). |
| `timestamp` | number | Where in the walkthrough video (`GET /v1/inspections/{inspection_id}/video`) the item was checked off, in seconds from the start: the same clock as a finding's `video_timestamp_seconds`. A number of seconds, never a date-time. Seek there to see the moment in motion. Present whenever the cleaner's tap was recorded, with or without a picture. |
| `reason` | string | On `explained` only: what the cleaner wrote. |

Use these instead of asking the cleaner for a photo per step. **Fetch the image
when you receive it and store the bytes, not the URL**: the link dies after 15
minutes, and `GET /v1/inspections?workspace_id=...&task_id=...` signs fresh ones on every
read. Every read signs a new link for the same picture, so
key your copy by the item's `id` and `state` (a new `state` means a new
result, and so possibly a new picture), not by the URL. Fetch it with a plain `GET`
and no auth header, and read the format from the response's `Content-Type`.

`pending` items carry none of these. `inspection.recording_completed` waits
until every item we judged from the footage has its picture, and until every
stored picture can be linked, within the same 2 minutes as the results; in the
rare case one is not ready by then (the video did not survive processing, or
the stored picture is gone), the event goes without it. Nothing is made for a
`not_checked` item that has no picture, so it never holds the event, and a few
items on walkthroughs recorded before 2026-10-08 may also lack one.

`id` is the checklist item's id from the checklist endpoints, so results join
straight back to the list you sent. It is omitted on the occasional item we
could not tie back to a current list entry. `items` is in the order the
cleaner worked through them, and holds the items the cleaner checked off.

The whole `checklist` key is **omitted** when the home has no checklist, rather
than sent as an empty block with zero counts: those are different claims, and
only one of them is true.

### Linking back to the full report

Findings over the API are the machine-readable half. The report is the page a
person opens, and every response that can carry a link to it does.

`report_url` is the same URL in every place, derived from the inspection, so
it is stable and safe to store alongside your own task:

| Where | When it appears |
|---|---|
| the `inspection.completed` webhook | always, on the event that says the report exists, at the top level and in `report` |
| `GET /v1/inspections?task_id=...` | in `report` once its `status` is `ready`, and at the top level on `"status": "completed"` |
| `GET /v1/inspections/{inspection_id}/findings` | always, since that endpoint already refuses anything earlier |

If you have only your own task id and want the link, that is the middle row and
it is one call:

```http
GET /v1/inspections?workspace_id=ws_7Qv3&task_id=clean-88231
```

Before the report exists, `report` carries `"status": "processing"` and no
`report_url`, because there is nothing to link to yet. Test for the key.

**The report page requires a RapidEye login.** It is a customer-facing page
inside the account, so whoever opens it needs a seat in that workspace. An
unauthenticated click lands on our login page and is forwarded to the report
after signing in, so the link is safe to put in front of someone who has an
account and has not signed in yet. It is not a public link, and it is not
something to paste into a channel that reaches people outside the workspace.
`POST /v1/workspace-claims` is how you get your customer their seat.

## Giving your customer access to their workspace

A workspace you provision has no people in it. Your customer can be given an
owner login for it, which lets them see their reports, properties and history in
RapidEye directly.

### `POST /v1/workspace-claims?workspace_id=...`

Nominate one email address as the workspace's first owner. Body is
`{ "email": "..." }`. Always answers `202`.

```json
{
  "workspace_id": "ws_123",
  "claim": {
    "state": "pending",
    "email": "ops@sunsetpm.example",
    "expires_at": "2026-09-23T06:00:00Z",
    "email_sent": true,
    "replaced_previous": false
  }
}
```

The rules, because they are narrow on purpose:

- Only for a workspace **you** created, that has no members yet and no owner.
  Anything else is `409 claim_requires_admin`; an already-owned workspace is
  `409 workspace_already_claimed`.
- One live nomination per workspace. Nominating a different address revokes the
  previous one.
- **Exact email only.** No domain matching, ever. The link goes to the address
  you named and nowhere else.
- The response never contains a token. You are nominating a person, not
  obtaining access, which is what stops a partner reading their own API response
  to sign in as their customer.

The person receives an email with a sign-in link. There is also a "Get access to
this workspace" path from the report sign-in page for a customer who reaches a
report before anyone nominated them; that one comes to us for review.

## What we send back to you

You give us one HTTPS receiver URL and we exchange a shared secret. Three
events, in the order they normally arrive, each named for what just finished:

| Event | Fires when | Typical timing |
|---|---|---|
| `inspection.recording_completed` | The cleaner finished the walkthrough, with each checklist item's result | Seconds after the walkthrough is sealed (usually under a minute after the cleaner taps Done); at most about 2 minutes after the seal while a result is still landing |
| `inspection.completed` | The report is ready | Hours after the walk, once review is done |
| `inspection.failed` | We could not produce the report | Instead of `inspection.completed` |

**`inspection.recording_completed`** — the cleaner finished the walkthrough, and
here is each checklist item's result. Use it to mark the checklist item complete
(the cleaner's part is done) and to drive a "verified by RapidEye" state; the
report is not ready yet. Same envelope as `inspection.completed` below, with
`recorded_at` instead of `completed_at`, no `report_url`, `video_url` or
`report`, and the `checklist` block (omitted for a home with no checklist).
Sent once per walkthrough, and not for a home that is switched off.

**It waits for the results, for at most 2 minutes.** We hold the event until
every checklist item has its final state and its picture, so `checklist.status`
is normally `complete`. The hold ends 2 minutes after the walkthrough is sealed
on our side (the moment the recording is safely saved). An item whose result is
still on its way then goes out `pending`, with `checklist.status` `in_progress`:
read its final state from `GET /v1/inspections?workspace_id=...&task_id=...`, or
from `inspection.completed`. In the week before this shipped, about 1
walkthrough in 50 had a result still landing at 2 minutes. A home with no
checklist is not held. A walkthrough uploaded as a file in RapidEye, rather than
recorded through your link, is not held either: its event goes when the upload
finishes, `in_progress`.

```json
{
  "version": 1,
  "event_id": "5d0b8f2e-6c1a-4f3e-9a77-1e2d3c4b5a69",
  "event_type": "inspection.recording_completed",
  "workspace_id": "ws_123",
  "task_id": "task_abc",
  "property_id": "unit_42",
  "inspection_id": "3b7f1a90-2c44-4b6d-9e01-7a5c2f8e1d33",
  "recorded_at": "2026-08-01T09:01:02Z",
  "checklist": {
    "status": "complete",
    "counts": { "verified": 9, "missed": 2, "explained": 1, "not_checked": 0, "pending": 0 },
    "items": [
      { "id": "c1f0a2e4-...", "name": "Bedrooms: beds made, linens fresh", "state": "verified",
        "timestamp": 84.2, "image_url": "https://...signed..." }
    ]
  }
}
```

Each `image_url` in the event is signed when the event is queued and lives 15
minutes. A first delivery lands within seconds, so fetch the images then; if
yours came on a retry and a link has expired, read
`GET /v1/inspections?workspace_id=...&task_id=...` for fresh ones.

`recorded_at` is when the walkthrough was sealed, so it sorts before the
report's `completed_at`.

**`inspection.completed`** — the inspection is processed and the report is ready.
It carries both blocks, `report` and `checklist` (omitted for a home with no
checklist). The top-level `report_url` and `completed_at` predate the `report`
block and stay:

```json
{
  "version": 1,
  "event_id": "9f1c4d3e-88a0-4a1e-9d2b-5c8e77c1b904",
  "event_type": "inspection.completed",
  "workspace_id": "ws_123",
  "task_id": "task_abc",
  "property_id": "unit_42",
  "inspection_id": "3b7f1a90-2c44-4b6d-9e01-7a5c2f8e1d33",
  "report_url": "https://app.rapideyeinspections.com/reports/3b7f1a90-2c44-4b6d-9e01-7a5c2f8e1d33",
  "video_url": "https://api.rapideyeinspections.com/v1/inspections/3b7f1a90-2c44-4b6d-9e01-7a5c2f8e1d33/video",
  "completed_at": "2026-08-01T17:04:33Z",
  "report": {
    "status": "ready",
    "report_url": "https://app.rapideyeinspections.com/reports/3b7f1a90-2c44-4b6d-9e01-7a5c2f8e1d33"
  },
  "checklist": {
    "status": "complete",
    "counts": { "verified": 9, "missed": 2, "explained": 1, "not_checked": 0, "pending": 0 },
    "items": [
      { "id": "c1f0a2e4-...", "name": "Bedrooms: beds made, linens fresh", "state": "verified" }
    ]
  }
}
```

`video_url` is the address of `GET /v1/inspections/{inspection_id}/video`, not
a download link. It never expires, which matters because a delivery can be
retried hours later. Call it with your partner key and the `workspace_id` from
this event to get a fresh download link.

**`inspection.failed`** is the same envelope with no `report_url`, `video_url`,
`report` or `checklist`, plus a `"reason"`. The checklist results are not lost
with the report: `inspection.recording_completed` has already carried them, and
`GET /v1/inspections` keeps carrying the block.

`task_id` and `property_id` are echoed back as **your** ids, so your handler can
look up the task without storing any RapidEye identifiers.

### Verifying our signature

Every delivery carries:

```
X-RapidEye-Signature: t=<unix_seconds>,v1=<hex hmac_sha256(secret, "{t}.{raw_body}")>
```

Recompute over the **raw body bytes** before any JSON parse. Reject if
`|now - t| > 300` seconds. Compare in constant time.

**There may be more than one `v1=`.** During a signing-secret rotation we sign
with the outgoing secret as well as the new one, so a delivery can carry two
entries, the current secret first. Accept the delivery if **any** entry matches.
The snippets below do that; verify yours does before we rotate anything, because
a verifier that keeps only one entry is the single way an overlap window fails.

Node / TypeScript:

```ts
import { createHmac, timingSafeEqual } from "node:crypto";

export function verifyRapidEyeSignature(rawBody: Buffer, header: string, secret: string): boolean {
  const pairs = header.split(",").map((kv) => kv.split("=", 2));
  const t = pairs.find(([k]) => k === "t")?.[1] ?? "";
  if (!/^\d+$/.test(t) || Math.abs(Date.now() / 1000 - Number(t)) > 300) return false;
  const signed = Buffer.concat([Buffer.from(`${t}.`, "utf8"), rawBody]);
  const expected = Buffer.from(createHmac("sha256", secret).update(signed).digest("hex"), "utf8");
  // EVERY v1, not just the first: a rotation sends two.
  return pairs
    .filter(([k]) => k === "v1")
    .some(([, v]) => {
      const b = Buffer.from(v ?? "", "utf8");
      return expected.length === b.length && timingSafeEqual(expected, b);
    });
}
```

(In Express, mount `express.raw({ type: "application/json" })` on the webhook
route so the raw bytes survive; `express.json()` breaks the HMAC.)

Python:

```python
import hashlib, hmac, time

def verify_rapideye_signature(raw_body: bytes, header: str, secret: str) -> bool:
    pairs = [kv.split("=", 1) for kv in header.split(",") if "=" in kv]
    t = next((v for k, v in pairs if k == "t"), "")
    if not t.isdigit() or abs(time.time() - int(t)) > 300:
        return False
    signed = t.encode() + b"." + raw_body
    expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest()
    # EVERY v1, not just the first: a rotation sends two.
    return any(hmac.compare_digest(expected, v) for k, v in pairs if k == "v1")
```

(In Flask, use `request.get_data()`, not `request.json`.)

> If you implemented against the earlier snippets, which parsed the header into
> a dict, upgrade before your first rotation. A dict keeps only the LAST `v1=`,
> so it would verify against whichever secret we happen to have put second.

### Rotating the webhook signing secret

We mint a new secret and keep the old one valid for an **overlap window**
(7 days by default). For the whole window every delivery carries two `v1=`
entries and either secret verifies, so there is no cutover instant and nothing to
coordinate:

1. We send you the new secret and the date the old one stops working.
2. You deploy it whenever suits you, any time before that date.
3. After the date we sign with the new secret only.

Your side needs the any-match verifier above **before** step 1. Tell us if you
need a longer window; tell us immediately if you think a secret has leaked, in
which case we cut the window short on purpose.

### Rotating your partner key

Same shape, no window to schedule: **two of your keys can be active at once**.

1. We issue a second key and send it to you. Both keys now work.
2. You deploy the new one.
3. We watch until the old key stops being used, then revoke it.

A revoked key answers `403 key_revoked`, never `401 invalid_key`, so if anything
was still using it the error says so plainly. Ask us to rotate whenever you want
one; there is no cost and no downtime.

### Delivery semantics

- **At-least-once.** Retries with backoff (1m, 5m, 30m, 2h, 12h) until you return 2xx.
- **Duplicates are expected.** Dedupe on `event_id` if you do anything beyond saving `report_url`.
- **Respond 2xx quickly**; do heavy work async. Non-2xx or timeout re-enters the retry schedule.
- **Ordering is not guaranteed.** Use `completed_at` if you need to order events.
- **Ignore event types you do not recognize** and answer them 2xx. We add
  events without a version change; registering a filter that names only the
  events you handle also works.
- **Nothing for a unit that is switched off.** If a unit is switched off in
  RapidEye while a walkthrough is in flight (your roster dropped it, or your
  customer turned it off), the walkthrough still finishes but we send no event
  for it, so no `video_url` arrives. Its report stays readable at
  `GET /v1/inspections/{inspection_id}/findings`, its video at
  `GET /v1/inspections/{inspection_id}/video`, and its status at
  `GET /v1/inspections?task_id=...`.

## RapidEye in Claude, ChatGPT, Claude Code and Codex (MCP)

RapidEye also runs a remote [Model Context Protocol](https://modelcontextprotocol.io)
server, so a RapidEye customer can use their own account from Claude, ChatGPT, Claude Code
or Codex.
It is a customer feature, not a partner integration surface: it acts as the person
who connected, never as a partner key, and partners keep using `/v1`.

**Address:** `https://api.rapideyeinspections.com/mcp`

A customer adds that address as a custom connector in Claude or ChatGPT, or with one
command in Claude Code or Codex (the customer guides below have each app's steps),
signs in with RapidEye, and chooses
between "Full access" and "Read only". The connection works in every workspace the person
is in, including ones they join later, with their role in each; Full access is
still limited to that role, and Read only can't use RapidEye tools that make changes. Write
tools work in Claude on every plan and in ChatGPT where the plan allows custom apps
to write (Business, Enterprise and Edu at the time of writing). The customer guides
are [Connect Claude](https://rapideyeinspections.com/docs/claude/) and
[Connect ChatGPT](https://rapideyeinspections.com/docs/chatgpt/).

### Protocol and sign-in

- Streamable HTTP, stateless, JSON responses: one `POST /mcp` per message. Protocol
  revisions `2025-06-18`, `2025-11-25` and `2026-07-28` are served. `GET` and
  `DELETE` on `/mcp` answer `405`.
- A request without a valid token answers `401` with
  `WWW-Authenticate: Bearer resource_metadata="https://api.rapideyeinspections.com/.well-known/oauth-protected-resource/mcp"`.
- Protected resource metadata (RFC 9728): `/.well-known/oauth-protected-resource/mcp`.
- Authorization server metadata (RFC 8414): `/.well-known/oauth-authorization-server`.
  Authorization code with PKCE `S256` only, public clients
  (`token_endpoint_auth_method: none`), Client ID Metadata Documents preferred and
  dynamic registration (RFC 7591) as the fallback, `resource` (RFC 8707) validated
  and bound to every token, `iss` in the authorization response (RFC 9207). Scopes:
  `rapideye` and `offline_access`. No OpenID Connect scopes.
- Only Claude (web, desktop and mobile), ChatGPT, Claude Code and Codex may connect.
  Claude Code and Codex are identified only by their vendors' Client ID Metadata
  Documents and receive the code at a loopback callback on the person's own computer
  (RFC 8252), never through dynamic registration. Any other
  client is refused before a person sees a consent screen.
- A person may connect one app more than once (two Claude accounts, or Claude Code on
  two computers). Each connection is listed, switched and disconnected on its own.
- Access tokens are opaque and last one hour; refresh tokens rotate, and a reused
  one ends the connection. A reuse within 10 seconds of the first is taken as the
  client's own retry: it is refused and the connection stays. A connection nobody
  uses for 90 days ends.

### Tools

Twenty-six tools, eleven that read and fifteen that write. Every write is annotated
as one (`readOnlyHint: false`): Claude asks the person before running it, and ChatGPT
may run an additive write without asking.

Every tool but `list_workspaces` takes `workspace`, a name or id from
`list_workspaces`. It may be left out when the person has one workspace; with several,
a call without it answers with an error listing them, never with data from a default.
Every answer names the workspace it came from.

| Tool | What it does |
|---|---|
| `list_workspaces` | The person's workspaces, their role in each, and whether the connection is read only. |
| `get_workspace_overview` | Who is signed in, their role, this month's numbers, reports awaiting sign-off, homes not recorded lately. |
| `get_setup_status` | What is left to set up, for the account or one home, with the next step for each item. |
| `find_homes` | Search homes; filter to off, missing a baseline or checklist, or not recorded in N days. |
| `get_home` | One home: status, baselines and rooms, checklists, last walkthroughs, report delivery, recording link. |
| `find_inspections` | Walkthroughs by date, home, type and state, the review queue included. |
| `get_inspection_report` | One report: findings, checklist outcomes, comments, tags, PMS tasks, and the transcript on request. |
| `find_findings` | Findings across homes by date, home, severity, category or tag, optionally grouped by home. |
| `view_finding_photo` | One finding's photo, or a video frame, as an image up to 1400 px on the long edge (a frame up to 1568 px), and on request a link to the original photo that opens for 15 minutes. |
| `get_performance` | Cleaner and home performance and cleaner accountability for a date range. |
| `get_settings` | Team, report delivery or PMS settings. |
| `add_homes` | Add up to 100 homes; duplicates are skipped. |
| `reactivate_home` | Turn a home that is off back on. |
| `start_walkthrough` | Create walkthroughs and return their recording links. |
| `save_checklist` | Replace or copy a checklist; the old one is returned. |
| `draft_checklist_from_video` | Draft a checklist from the baseline video. Saves nothing. |
| `update_baseline` | Import photos, attach a scan, reuse another baseline's material, name baselines and rooms. |
| `annotate_finding` | Comment on a finding, edit your own comment, add or remove tags. |
| `mark_report_reviewed` | Sign off up to 10 reports. |
| `create_pms_task` | Create a PMS task from a finding, with its photo, or add it to an existing task. |
| `manage_team` | Invite, resend or cancel invites, change roles, answer join requests. |
| `update_slack` | Default and per-home Slack channels, matching by name. |
| `update_email_recipients` | Change who gets report emails and the morning summary. |
| `set_up_pms` | Choose and connect the PMS, check it, change what it does, and save a Trellis or Track key the person pastes into the chat. Owners and admins; only the key's last four characters are ever shown back. |
| `complete_setup_step` | Record a Get Started step. |
| `update_accountability_settings` | Set the short-walkthrough threshold. |

These stay in the dashboard and have no tool: deleting a walkthrough or a home,
removing a teammate or transferring ownership, turning a home off, billing,
disconnecting Slack or a PMS, re-recording, unlinking a PMS task, deleting a
comment, and removing baseline photos, rooms or scans. The API and the database
refuse them for these connections too.

### Limits

Per connection: 60 tool calls a minute, 2,000 a day and 20 writes a minute. Per
workspace: 300 calls a minute across its connections. Lists page at 50, and a
report with more than 25 findings lists the 25 most severe.

## What we need from you

1. An HTTPS webhook receiver URL.
2. Your customers' property rosters, either pushed to `POST /v1/roster-imports`
   or served from a feed we poll. Nothing else about properties.
3. Nothing for workspaces. You provision them yourself with
   `POST /v1/workspaces`. Tell us only when a customer of yours is already a
   RapidEye customer, so we can link the two accounts rather than run them in
   parallel.
4. Where the RapidEye link will live in your task UI. We highly suggest rendering
   it as a button on the checklist item, and marking that item complete when
   `inspection.recording_completed` arrives.
5. If you want cleaners sent back to your app after the walkthrough: the app
   scheme or web origin to put on your `return_url` allowlist.

## What you get from us

1. Your partner key (`rapideye_pk_...`), delivered out of band.
2. Self-serve workspace provisioning and roster import under that key. No ticket,
   no wait.
3. The webhook signing secret.
4. Direct access to our engineers at support@rapideyeinspections.com while you
   build; we iterate fast.

## Moving to the part blocks (2026-10-08)

If you built against the guide before 2026-10-08, one thing you read has
changed: the checklist item states `not_done` and `unknown`, and their `counts`
keys, are gone, so step 2 is required. Everything else you read is still
there, and the other three steps are worth making:

1. **Read results from `checklist`, not from the completed report.** Drive your
   "verified by RapidEye" state from the `checklist` on
   `inspection.recording_completed`, or from `GET /v1/inspections` once
   `checklist.status` is `complete`. That is seconds after the walk instead of
   hours.
2. **Switch to the new item states (required).** `verified`; `missed` (was
   `not_done`, and now also an item `unknown` because the walkthrough did not
   show it clearly); `explained` (with the cleaner's `reason`); `not_checked`
   (was `unknown` when we could not check the item: not a result about the
   cleaner, so do not count it as missed); and `pending`, which you only see
   while `checklist.status` is `in_progress`; treat it as "checking". A
   "Finish anyway" item now reads `missed`, as it does in the report.
3. **Read the report from `report`.** `report.status` is `ready` exactly when
   the inspection `status` is `completed`, and `report.report_url` is the same
   link as the top-level `report_url`, which stays. While the report is being
   produced, `report.stage` says whether it is `analyzing` or `reviewing`.
4. **Take each step's picture from `image_url`** instead of asking the cleaner
   for one, and store the bytes, since the link lives 15 minutes.

## Changelog

| Version | Date | Change |
|---|---|---|
| v1 | 2026-09-18 | Initial release. |
| v1 | 2026-09-19 | **Workspaces switch on by themselves.** A roster import that reaches `applied` now activates every property it lists as `active` and puts the account into service, a few seconds after the import is marked `applied`. The "email us to activate" step is gone. `property_not_activated` and `workspace_not_enabled` still exist but are rare; sending the roster again with a new `Idempotency-Key`, or triggering a sync, clears them. See *Overview*, *Authentication and errors*, *Workspaces*, *Roster* and *Recording links and inspections*. |
| v1 | 2026-09-19 | **Three checklists per home.** The checklist endpoints take an optional `?type=` of `housekeeping` (the default), `inspection` or `maintenance`, and every response echoes `type`. Omitting it behaves exactly as before. See *Home setup*. |
| v1 | 2026-09-19 | **Checklist items carry an `id`.** `GET` and `PUT` return our id for each item, which is what per-item results are keyed on. An id is stable between writes and not across them: a `PUT` issues new ones. See *Home setup*. |
| v1 | 2026-09-19 | **Per-item checklist results.** A `checklist` block with each item's `state` (`verified`, `not_done` or `unknown`) and a `counts` summary, on `GET /v1/inspections` when `completed` and on the findings response. Omitted when the home has no checklist. See *Recording links and inspections*. |
| v1 | 2026-09-19 | **`report_url` on the findings response.** The link to the full report now comes back with the findings, as well as on the status endpoint and the `inspection.completed` webhook. See *Recording links and inspections*. |
| v1 | 2026-09-23 | **Baselines are one set, and removing a tour removes it.** `setup.baseline_photos.count` now counts every photo in the home's baseline, not only the ones your imports and our Google Drive imports added: managers can now add photos themselves (uploads, Drive, their Airbnb listing, Breezeway listing photos). `duplicates` on a photo import now also counts a photo the home already holds from any of those. `DELETE .../matterport` now deletes the tour's imported imagery and stops a running import; before, it only forgot the link and the imagery stayed in use. Sending a different tour replaces the imported one once the new import succeeds. A home runs one import at a time, of any kind: a tour sent while photos import (or photos while a tour imports) answers the other kind's `*_import_in_progress` 409. See *Home setup*. |
| v1 | 2026-10-04 | **No events for a unit that is switched off.** A walkthrough in flight when its unit is switched off in RapidEye still finishes, and its report stays readable from the findings endpoint (and, since the 2026-10-05 row, its video from the video endpoint), but `inspection.recording_completed`, `inspection.completed` and `inspection.failed` are not sent for it. A unit that is off records nothing new, as before. See *What we send back to you*. |
| v1 | 2026-10-05 | **Download the walkthrough video.** New `GET /v1/inspections/{inspection_id}/video` returns a signed download link for the inspection's video, valid 15 minutes, with `expires_at`, `content_type`, `duration_seconds`, `size_bytes` and `recorded_at`. Each call returns a fresh link. The `inspection.completed` webhook now carries `video_url`, the address of that endpoint, which never expires. Two new codes: `409 video_not_ready` and `404 video_not_available`. See *Recording links and inspections* and *Webhooks*. |
| v1 | 2026-10-08 | **Checklist results before the report, and a block per part.** `inspection.recording_completed` now carries the `checklist` block with every item's result: it waits until every item is final and pictured, at most 2 minutes after the walkthrough is sealed, and an item still landing then reads `pending`. Every response and event describes the inspection's two parts as blocks with their own status: `checklist` (`in_progress` or `complete`) and `report` (`processing`, with a `stage` of `analyzing` or `reviewing`, or `ready`, with `report_url`). `GET /v1/inspections` carries `checklist` on every status and `report` on `processing` and `completed`; `inspection.completed` carries both. Item states are now the report's four, `verified`, `missed`, `explained` and `not_checked`, plus `pending` while a verdict is on its way: `not_done` is renamed `missed`; what was `unknown` is `missed` when the walkthrough did not show the item clearly and `not_checked` when we could not check it; a cleaner's explanation is `explained` with their `reason`; and a "Finish anyway" item, which read `verified`, is `missed`, matching the report. `counts` names the five. The status poll signs the pictures afresh on every read. Every item with a result carries `image_url` when we have the picture, a 15-minute signed still from the walkthrough at the moment it was checked off (the picture the report shows), and `timestamp`, so a cleaner no longer needs to photograph each step. `GET /v1/inspections` now carries `inspection_id` from the moment the cleaner opens the link. The `completed` arm's top-level `inspection_id`, `report_url` and `completed_at` are unchanged. See *Per-item checklist results*, *`GET /v1/inspections`* and *What we send back to you*. |
| v1 | 2026-10-09 | **Send the cleaner back to your app.** `GET /v1/recording-link` takes an optional `return_url`, checked against your integration's allowlist (`400 invalid_request` with `detail.parameter: "return_url"` otherwise). Once the walkthrough is sealed, the cleaner's last screen shows for 3 seconds with a **Return to {your name}** button, then navigates to that URL with `inspection_id` and `status=submitted` added. A link requested without it keeps today's last screen. See *Sending the cleaner back to your app*. |

One change alters a shape you may already read: on 2026-10-08 the checklist
item states and their `counts` keys changed. `not_done` and `unknown` are gone
from both, `missed`, `explained`, `not_checked` and `pending` are new, and a "Finish anyway"
item went from `verified` to `missed` (*Moving to the part blocks*, step 2).
Every other new field and parameter is optional or additive, which is why
these sit under `v1` rather than a new version. The other changes in
behaviour are the first row, the 2026-09-23 row and the 2026-10-04 row. `service.state`,
`recording_enabled` and `billing_active` now move to on when an import applies,
rather than when we switch them.
