Reference
Roster
Import each customer's complete property list, by push or by feed.
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 whenstatusis omitted) is switched on for inspections, and the account with it. One you send asinactive, or leave out, is deactivated, as above. The result is reported asbilling_activeandroster.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.
{
"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. |
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.
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" } }
]
}'
{
"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
}
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.
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.
{
"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 }
}
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. |
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.
{
"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=="
}
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.