## What we send back to you

Signed `POST` deliveries to your HTTPS receiver when an inspection finishes a stage.

- **Register:** send us one HTTPS receiver URL. We send back its signing secret.
- **Reservations partners** receive `workspace.*` and `reservation.*` instead of the three events below (*Events*). Everything from *Verifying our signature* on applies to both.
- **Your ids:** `task_id` and `property_id` are echoed back as yours, so your handler can find the task without storing any RapidEye id.

| 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 |
| `inspection.failed` | We could not produce the report | Instead of `inspection.completed` |

**`inspection.recording_completed`**: the cleaner's part is done, with each checklist item's result. The report is not ready yet.

- **Shape:** the `inspection.completed` envelope 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).
- **Once per walkthrough**, and never for a home that is switched off.
- **Waits up to 2 minutes after the walkthrough is sealed** for every item's result, so `checklist.status` is normally `complete`. An item still on its way goes out `pending` with `checklist.status` `in_progress`; read its final state from `GET /v1/inspections?workspace_id=...&task_id=...` or `inspection.completed`. A home with no checklist, or a walkthrough uploaded as a file in RapidEye, is not held.
- **`image_url`** lives 15 minutes from when the event is queued. After a retry, read `GET /v1/inspections?workspace_id=...&task_id=...` for fresh links.

```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..." }
    ]
  }
}
```

**`inspection.completed`**: the report is ready.

- **Once for every finished inspection**, including one where nothing was found. Not sent if `delivery` cannot be filled in within two days.
- **Blocks:** `report`, `checklist` (omitted for a home with no checklist) and `delivery`. The top-level `report_url` and `completed_at` stay.
- **`delivery`** says whether the report was sent to the property manager's team, following what they chose to receive in RapidEye. `sent` is `true` or `false`. `reason` is `findings`, `missed_checklist_steps` (only for a manager who chose to receive missed steps), `below_threshold` (findings, all below the level the manager reports on) or `clean` (nothing found, or only missed steps the manager does not receive). A `clean` inspection still has a report at `report_url`, and its `checklist` block still lists every missed step.
- **`video_url`** is the address of `GET /v1/inspections/{inspection_id}/video`, not a download link, and never expires. Call it with your key and this event's `workspace_id` for a fresh download link.

```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" }
    ]
  },
  "delivery": { "sent": true, "reason": "missed_checklist_steps" }
}
```

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

### Verifying our signature

Every delivery is an HTTP `POST` signed with HMAC-SHA256 under your receiver's own secret, in the [Standard Webhooks](https://www.standardwebhooks.com) format. Any Standard Webhooks or Svix library verifies it with the secret as we send it.

With the example secret `whsec_ZXhhbXBsZS1zZWNyZXQtZm9yLXRoZXNlLWRvY3Mtb25seQ==`, this delivery verifies byte for byte (its `reservation` is shortened here):

```http
POST /your/receiver HTTP/1.1
content-type: application/json
user-agent: RapidEye-Webhooks/1
webhook-id: 7d3a91c2-5e0b-4f6a-9c12-3b8e4f1a2d77
webhook-timestamp: 1792600985
webhook-signature: v1,mc+9aG5cqbyfKBb3M4rAEsPTOp5NX8hOa62Ajeq7s7A=

{"changes":{"status":["confirmed","cancelled"]},"event_id":"7d3a91c2-5e0b-4f6a-9c12-3b8e4f1a2d77","event_type":"reservation.updated","occurred_at":"2026-10-21T16:40:12+00:00","reservation":{"reservation_id":"8c1e4b7a-90d2-4f55-a3f1-1d2e9b0c6a44","revision":4,"status":"cancelled"},"version":1,"workspace_id":"rwd_7Hq2c9Lm4TzPx8Ab"}
```

| Header | Value |
|---|---|
| `webhook-id` | The payload's `event_id`. The same on every retry. |
| `webhook-timestamp` | Unix seconds when this attempt was signed. |
| `webhook-signature` | `v1,` then the base64 HMAC-SHA256 of `{webhook-id}.{webhook-timestamp}.{raw body}`. Space-separated; two entries during a secret rotation, the current secret first. |

To verify by hand:

1. **Take the key.** The secret is `whsec_` followed by base64; the HMAC key is the base64 decode of the part after `whsec_`. A secret we sent you without the `whsec_` prefix is the raw key; for a library, its `whsec_` form is `whsec_` followed by the base64 of its UTF-8 bytes.
2. **Use the raw body bytes**, before any JSON parse. A framework's parsed JSON breaks the HMAC (in Flask read `request.get_data()`; in Express mount `express.raw({ type: "application/json" })` on the route).
3. **Compute** base64(HMAC-SHA256(key, `{webhook-id}.{webhook-timestamp}.` + raw body)).
4. **Reject a stale timestamp:** more than 300 seconds from now, either way. We sign every attempt afresh, so a retry is never stale.
5. **Accept if any `v1,` entry matches**, compared in constant time. Check every entry, not only the first: a rotation sends two.
6. **Dedupe on `webhook-id`.**

#### The earlier header: `X-RapidEye-Signature` (deprecated)

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

- Deprecated, still sent on every delivery under the same secret and timestamp. Build on `webhook-*`; we will agree a date with anyone still verifying this one before we stop. Reservations partners ignore it.
- Key: the base64 decode of the text after `whsec_`. Verify over the raw body bytes, reject when `|now - t| > 300` seconds, compare in constant time.
- **Accept if any `v1=` matches.** During a rotation there are two, the current secret first. A verifier that keeps only one (a dict keeps the last) fails mid-rotation.

### Rotating the webhook signing secret

1. We send you a new secret and the date the old one stops working, 7 days later by default (ask if you need longer).
2. You deploy it any time before that date. Until then every delivery carries two entries in each signature header and either secret verifies, so your verifier must accept any matching entry before we start.
3. After the date we sign with the new secret only.

If you think a secret has leaked, tell us at once and we cut the window short.

### Rotating your partner key

Two of your keys can be active at once, so a rotation has no downtime and no cost. Ask whenever you want one.

1. We issue a second key and send it to you. Both keys 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`.

### Delivery semantics

- **At least once, for about 14 and a half hours.** Six attempts: once, then retries with backoff (1m, 5m, 30m, 2h, 12h). After the sixth failure we stop; the API still has the change.
- **Answer 2xx within 10 seconds** and do heavy work asynchronously. A non-2xx, a timeout or a redirect re-enters the retry schedule; we do not follow redirects.
- **Decided again before every attempt.** An event is not sent once we pause your integration, once none of your keys holds the scope its type needs (`read:inspections` for `inspection.*`, `read:workspaces` for `workspace.*`, `read:reservations` for `reservation.*`), or once the workspace it names is no longer yours. We do not send it later; the API is the record.
- **Duplicates are expected.** Dedupe on `webhook-id` (the payload's `event_id`) if you do anything beyond saving `report_url`.
- **Order is not guaranteed.** Order inspection events by `completed_at`, and reservation events by a stay's `revision`.
- **Ignore event types you do not recognize** and answer them 2xx. We add events without a version change. A filter naming only the events you handle also works.
- **No inspection event for a unit that is switched off** while a walkthrough is in flight (your roster dropped it, or your customer turned it off). The walkthrough still finishes, but no event and no `video_url` arrive. Read its report 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=...`. A `reservation.*` event for a stay at a home that went off is still sent, cut to the stub (*Stubs*).
