## 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.
