Reference
Webhooks
Signed events on recording, checklist and report completion, with rotation.
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.
{
"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:
{
"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:
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:
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:
- We send you the new secret and the date the old one stops working.
- You deploy it whenever suits you, any time before that date.
- 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.
- We issue a second key and send it to you. Both keys now work.
- You deploy the new one.
- 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_idif you do anything beyond savingreport_url. - Respond 2xx quickly; do heavy work async. Non-2xx or timeout re-enters the retry schedule.
- Ordering is not guaranteed. Use
completed_atif 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_urlarrives. Its report stays readable atGET /v1/inspections/{inspection_id}/findings, its video atGET /v1/inspections/{inspection_id}/video, and its status atGET /v1/inspections?task_id=....