## 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 usually takes under a minute after the
   cleaner taps Done.
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": "analyzing" },
  "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`: the walkthrough is being analyzed. It stays `analyzing` until the report is ready |
| `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.

**`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 the report is ready 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 before the
report is ready. 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.** 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 result lands shortly after the cleaner checks the item off,
most of them while the walk is still going, so the block is available long
before the report is ready.

```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 that cannot be checked
is closed as `not_checked` shortly after the walk ends, 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` | This step could not be checked. Not a result about the cleaner's work; do not count it as missed. | Only when the walkthrough has a frame for 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 could not be checked at first may be checked again later, for up
  to 24 hours. While that happens 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's picture is ready and can be linked, within the same 2 minutes as
the results; in the rare case one is not ready by then, 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.
