{"openapi":"3.1.0","info":{"title":"ReelQuoteAI US API","version":"1.1.0","description":"Video-based moving inventory and quoting. A customer films a walkthrough; the API returns an item-level inventory and a firm, bookable price from the mover's own rate card.\n\n**Auth**: `Authorization: Bearer <key>`. Publishable keys (`pk_live_`) are safe in a browser and drive the customer funnel. Secret keys (`sk_live_`) are server-only and unlock `/quote`, `GET /jobs` and `/jobs/{id}/outcome`. Rate limits are per key: 60/min publishable, 300/min secret; a 429 carries `Retry-After` in seconds.\n\n**Money is always integer cents with an explicit currency.** The contract is frozen: fields are added, never renamed or repurposed.\n\n**Errors** are always `{ error, message }` — see the `Error` schema for every code. **Job status** is the `JobStatus` enum, in funnel order.\n\n**Idempotency**: send `Idempotency-Key` on `POST /jobs` and a retry replays the original 201 (`Idempotent-Replayed: true`) instead of creating a second job; the same key with a different body is a 422. `POST /jobs/{id}/book` is idempotent per job — a repeat returns the existing request with `already_requested: true` and sends nothing twice.\n\n**Scopes**: a key may be limited to a subset of the API; every operation below names the scope it needs as `x-scope`. `quote` — Price without a job: /ballpark, /quote, /catalog, /home-profiles, /availability, /session, /places. `jobs:read` — Read jobs: GET /jobs, GET /jobs/{id}, the cube sheet, date prices. `jobs:write` — Create and drive jobs: POST /jobs and every job action except outcomes. `outcomes` — Report what the move actually took: POST /jobs/{id}/outcome. `keys` — Manage the integration: list, mint, rotate and revoke API keys; read and replay webhook deliveries. A key with no scope list has everything its kind allows; a publishable key can never carry `outcomes` or `keys`. Out of scope is a 403 `insufficient_scope`. Mint, list, rotate and revoke keys from the mover's keys page or `/keys`.\n\n**Server-side handoff**: a caller that already holds the walkthrough posts a public https URL to `POST /jobs/{id}/video` instead of driving the browser upload; we fetch it in the background and the job runs as usual.\n\n**MCP**: the same API as tools at `/mcp` (Streamable HTTP, JSON-RPC 2.0; one tool per operation, named by `operationId`). Send the key as `Authorization: Bearer <key>` on the connection.\n\n**Sandbox**: keys minted in test mode (`pk_test_`, `sk_test_`) behave exactly like live keys except that their jobs are sandbox jobs: `POST /jobs/{id}/submit` completes at once with a fixed 13-item inventory priced by the mover's real rate card (no upload needed), nothing is billed, no email is sent, webhooks carry `livemode: false`, outcomes mint no credit, and a test key sees only test jobs. Build the whole integration against a sandbox pair, then swap the keys.\n\n**The funnel, as calls**: `POST /ballpark` (free, no job) → `POST /jobs` → `POST /jobs/{id}/contact` → `POST /jobs/{id}/upload-url` → PUT the video → (`/upload-complete`) → `POST /jobs/{id}/submit` → poll `GET /jobs/{id}` until `complete`/`quoted` → `/access`, `/items`, `/date` as needed → `/approve` → `/book`."},"servers":[{"url":"https://widget.reelquoteai.com/api/v1"}],"components":{"securitySchemes":{"bearer":{"type":"http","scheme":"bearer","description":"pk_live_ or sk_live_ key"}},"schemas":{"Error":{"type":"object","properties":{"error":{"type":"string","enum":["invalid_request","secret_key_in_url","unauthorized","secret_key_required","insufficient_scope","last_publishable_key","webhook_paused","video_in_flight","video_already_supplied","not_found","no_rate_card","ballpark_not_bookable","already_requested","idempotency_in_progress","idempotency_key_reused","rate_limited","internal_error","not_implemented","address_lookup_failed","address_lookup_unavailable","upload_unavailable"],"description":"Stable machine code. Branch on this."},"message":{"type":"string","description":"Human-readable; wording may change. Safe to show to a person."}},"required":["error","message"],"description":"Every non-2xx response has exactly this shape. A 429 also carries `Retry-After` (seconds). Codes: invalid_request (400), secret_key_in_url (400), unauthorized (401), secret_key_required (403), insufficient_scope (403), last_publishable_key (409), webhook_paused (409), video_in_flight (409), video_already_supplied (409), not_found (404), no_rate_card (409), ballpark_not_bookable (409), already_requested (409), idempotency_in_progress (409), idempotency_key_reused (422), rate_limited (429), internal_error (500), not_implemented (501), address_lookup_failed (502), address_lookup_unavailable (503), upload_unavailable (503)."},"JobStatus":{"type":"string","enum":["created","uploading","uploaded","queued","processing","complete","quoted","booked_requested","booked","declined","error"],"description":"Funnel order. **created** — The job exists. Nothing uploaded yet. **uploading** — An upload URL was issued; the video is on its way. **uploaded** — A chunked upload was assembled (a single PUT skips this state). **queued** — POST /submit accepted; waiting for the analysis worker. **processing** — The walkthrough is being analysed. Poll GET /jobs/{id}; typically 1–4 minutes. **complete** — Inventory ready. The firm quote is computed on the first read. **quoted** — A firm, bookable quote exists (set by the first GET after complete). **booked_requested** — The customer asked to book; the mover has not answered yet. **booked** — The mover confirmed the booking. **declined** — The mover turned the request down. `decline_reason` may say why. **error** — Analysis failed. `error` carries a message; upload again or contact the mover."},"Bucket":{"type":"object","properties":{"label":{"type":"string","description":"Plain-language line name"},"cents":{"type":"integer","description":"This line, in integer cents. Currency travels beside it — never assume USD."}},"additionalProperties":true,"description":"One line of the price: a label and an amount. Labour lines also carry hours and crew; truck legs carry miles."},"Quote":{"type":"object","properties":{"kind":{"type":"string","enum":["ballpark","firm"]},"bookable":{"type":"boolean","description":"true only for a firm quote built from a filmed inventory. Gate any booking action on this flag, never on kind."},"currency":{"type":"string","description":"ISO 4217","example":"USD"},"units":{"type":"string","description":"Measurement system for volumes and distances","example":"imperial"},"crew":{"type":"integer","description":"Movers on the job"},"labour_hours":{"type":"number","description":"Billable hours at the home(s)"},"overage":{"type":"object","properties":{"label":{"type":"string"},"hourly_cents":{"type":"integer","description":"Rate for time beyond the estimate, in integer cents. Currency travels beside it — never assume USD."},"same_as_hourly":{"type":"boolean"}},"description":"Disclosed on every quote: what an hour past the estimate costs."},"buckets":{"type":"object","properties":{"labour":{"$ref":"#/components/schemas/Bucket"},"move_leg_time":{"$ref":"#/components/schemas/Bucket"},"deadhead_leg_time":{"$ref":"#/components/schemas/Bucket"},"truck_move_leg":{"$ref":"#/components/schemas/Bucket"},"truck_deadhead_leg":{"$ref":"#/components/schemas/Bucket"},"travel_flat":{"$ref":"#/components/schemas/Bucket"},"access_origin":{"$ref":"#/components/schemas/Bucket"},"access_destination":{"$ref":"#/components/schemas/Bucket"}},"description":"The price, line by line."},"applied_rules":{"type":"array","items":{"type":"object","properties":{"rule":{"type":"string","description":"Rule name from the mover's rate card, e.g. weekend, month_end"},"mult":{"type":["number","null"],"description":"Multiplier, when the rule is one"},"add_cents":{"type":["integer","null"],"description":"Flat addition, when the rule is one, in integer cents. Currency travels beside it — never assume USD."},"delta_cents":{"type":"integer","description":"What the rule changed the total by, in integer cents. Currency travels beside it — never assume USD."}}},"description":"Date and seasonal rules that fired, in order. Empty when none did."},"subtotal_cents":{"type":"integer","description":"Before date rules and the minimum charge, in integer cents. Currency travels beside it — never assume USD."},"minimum_charge_cents":{"type":"integer","description":"The mover's floor, in integer cents. Currency travels beside it — never assume USD."},"minimum_charge_applied":{"type":"boolean","description":"true when the floor lifted the total"},"total_cents":{"type":["integer","null"],"description":"The price. null on a ballpark — use low_cents / high_cents, in integer cents. Currency travels beside it — never assume USD."},"low_cents":{"type":"integer","description":"Ballpark only: low end of the range, in integer cents. Currency travels beside it — never assume USD."},"high_cents":{"type":"integer","description":"Ballpark only: high end of the range, in integer cents. Currency travels beside it — never assume USD."},"inputs":{"type":"object","properties":{"cuft":{"type":"number","description":"Volume priced"},"weight_lbs":{"type":"integer"},"distance_miles":{"type":["number","null"]},"duration_minutes":{"type":["number","null"],"description":"Routed drive time, when a route was found"},"move_date":{"type":["string","null"],"format":"date","description":"YYYY-MM-DD"}},"additionalProperties":true,"description":"What the price was computed from — pinned on the job, so a later rate-card change can never reprice history. A ballpark adds the home size (dwelling) and its volume range (cuft_low, cuft_high)."},"assumptions":{"type":"array","items":{"type":"string"},"description":"Plain-language caveats. Show these to the human."},"rate_card_id":{"type":"string","description":"The mover's rate card this was priced from"},"rate_card_name":{"type":"string"},"engine_version":{"type":"string","description":"Pricing engine version","example":"us-v1.0.0"}},"required":["kind","bookable","currency","crew","labour_hours","buckets","total_cents","assumptions"],"description":"A priced quote. bookable=false means a ballpark range; only bookable quotes can be booked. The key kind decides the view. A publishable key — it sits in the mover's page source — receives the customer view: each line's label, amount, hours, crew and miles, the overage rate, the assumptions and the inputs. A secret key also receives the rate behind each line, applied_rules, subtotal_cents, minimum_charge_cents, rate_card_id, rate_card_name and engine_version."},"PriceSteps":{"type":"object","properties":{"up":{"type":["object","null"],"properties":{"cuft_delta":{"type":"number","description":"Cubic feet added (up) or removed (down) before the price changes"},"total_cents":{"type":"integer","description":"The price at that point, in integer cents. Currency travels beside it — never assume USD."},"crew":{"type":"integer"},"labour_hours":{"type":"number"}},"description":"The nearest price break in one direction; null when none is close."},"down":{"type":["object","null"],"properties":{"cuft_delta":{"type":"number","description":"Cubic feet added (up) or removed (down) before the price changes"},"total_cents":{"type":"integer","description":"The price at that point, in integer cents. Currency travels beside it — never assume USD."},"crew":{"type":"integer"},"labour_hours":{"type":"number"}},"description":"The nearest price break in one direction; null when none is close."}},"description":"How far the inventory can move before the price does — the honest answer to 'what if I add the piano'."},"AddressInput":{"type":"object","properties":{"address":{"type":"string","description":"Free text as typed, or as resolved by /places/resolve"},"unit":{"type":"string","description":"Apartment or suite. A number like 2104 lets the floor be inferred."},"place_id":{"type":"string","description":"From /places/resolve, when address autocomplete was used"},"dwelling":{"type":"string","description":"apartment, condo, townhouse, house or storage — free text is normalised"}},"description":"One end of the move, as the customer gives it."},"AccessProfile":{"type":"object","properties":{"address":{"type":["string","null"]},"unit":{"type":["string","null"]},"place_id":{"type":["string","null"]},"dwelling":{"type":["string","null"],"enum":["apartment","condo","townhouse","house","storage"]},"floor_inferred":{"type":["integer","null"],"description":"Read off the unit number; null when it could not be"},"floor_confirmed":{"type":["integer","null"],"description":"What the customer confirmed via /access"},"elevator":{"type":["boolean","null"],"description":"null = not yet answered"},"elevator_suggested":{"type":"boolean","description":"true when the inferred floor makes an elevator likely; a UI pre-checks the box"},"stairs_flights":{"type":["integer","null"]},"parking_to_door_ft":{"type":["integer","null"],"description":"Carry distance from where a truck can park"},"access_confirmed_at":{"type":["string","null"],"format":"date-time"}},"description":"One end of the move as the system holds it: what was inferred, and what the customer confirmed. Both survive, so inferred-vs-confirmed is measurable."},"AccessAnswers":{"type":"object","properties":{"floor":{"type":"integer","description":"0–199"},"elevator":{"type":"boolean"},"stairs_flights":{"type":"integer","description":"0–30"},"parking_to_door_ft":{"type":"integer","description":"0–2000"}},"description":"What a customer can actually answer about one end. Only these fields are writable; inferred values are kept."},"InventoryItem":{"type":"object","properties":{"id":{"type":"string","description":"Row id. Pass it back to POST /jobs/{id}/items to edit this row."},"item_key":{"type":"string","description":"Catalog key (GET /catalog). `misc` when nothing on the catalog fit."},"name":{"type":"string","description":"As detected in the video, or as the catalog names a customer-added row"},"quantity":{"type":"integer"},"cubic_feet":{"type":"number","description":"Per unit, copied from the catalog at analysis time"},"weight_lbs":{"type":"number","description":"Per unit, copied from the catalog at analysis time"},"fragile":{"type":"boolean"},"requires_disassembly":{"type":"boolean"},"two_person":{"type":"boolean"},"special_handling":{"type":"boolean"},"excluded":{"type":"boolean","description":"true = staying / not moving. Kept on the record with its volume unpriced; never deleted."},"confidence":{"type":["number","null"],"description":"0–1 from the analysis; null on customer-added rows"},"notes":{"type":["string","null"],"description":"From the narration or the customer"},"source":{"type":"string","enum":["ai","customer_added"],"description":"Who put the row here."}},"required":["id","item_key","name","quantity","cubic_feet","excluded","source"]},"Room":{"type":"object","properties":{"room":{"type":"string"},"items":{"type":"array","items":{"$ref":"#/components/schemas/InventoryItem"}}}},"GapPrompt":{"type":"object","properties":{"code":{"type":"string","description":"Stable code, e.g. bedrooms_missing"},"severity":{"type":"string","enum":["warn","info"]},"title":{"type":"string"},"message":{"type":"string","description":"Customer-voiced, e.g. 'only 1 of 3 bedrooms is in the video'"}},"additionalProperties":true},"Gaps":{"type":"object","properties":{"prompts":{"type":"array","items":{"$ref":"#/components/schemas/GapPrompt"},"description":"Recomputed from the CURRENT inventory on every read — fixing a gap removes its prompt. Empty is the common, good case."},"acknowledged_at":{"type":["string","null"],"format":"date-time","description":"When the customer said 'nothing is missing' via POST /jobs/{id}/gaps"}}},"Inventory":{"type":"object","properties":{"item_count":{"type":"integer","description":"Rows"},"total_quantity":{"type":"integer","description":"Units across all rows"},"total_cuft":{"type":"number","description":"Priced volume: excluded rows contribute nothing"},"total_weight_lbs":{"type":"number"},"estimated_boxes":{"type":["integer","null"],"description":"Medium boxes the analysis estimated from closets and cupboards; also present as a box row"},"access_notes":{"type":["string","null"],"description":"Stairs, elevators, long carries seen or heard in the video"},"video_quality_issues":{"type":["string","null"],"description":"Rooms skipped, closed closets, poor light — or 'None'"},"approved_at":{"type":["string","null"],"format":"date-time","description":"Customer sign-off; cleared whenever the inventory is edited"},"customer_notes":{"type":["string","null"]},"rooms":{"type":"array","items":{"$ref":"#/components/schemas/Room"}},"packing":{"type":["object","null"],"properties":{},"additionalProperties":true,"description":"Display-only packing-materials estimate derived from this inventory (boxes by size, tape, paper). Quantities, not prices."},"gaps":{"$ref":"#/components/schemas/Gaps"}},"required":["item_count","total_cuft","rooms"]},"Contact":{"type":"object","properties":{"email":{"type":["string","null"]},"name":{"type":["string","null"]},"phone":{"type":["string","null"]}},"description":"What this customer gave for this job — so a resume on another device does not ask twice. Never anything the mover added."},"Terms":{"type":"object","properties":{"included":{"type":["string","null"],"description":"What the mover includes, in their words"},"terms":{"type":["string","null"],"description":"The mover's terms, in their words"},"frozen":{"type":"boolean","description":"false = live text; true = the snapshot the customer accepted at booking"},"accepted_at":{"type":["string","null"],"format":"date-time","description":"Only on a frozen copy"}}},"Job":{"type":"object","properties":{"job_id":{"type":"string","description":"ULID"},"status":{"$ref":"#/components/schemas/JobStatus"},"quote_kind":{"type":"string","enum":["ballpark","firm"],"description":"What the job was started for; a firm job gets a bookable quote once filmed."},"move_date":{"type":["string","null"],"format":"date","description":"YYYY-MM-DD"},"inventory":{"$ref":"#/components/schemas/Inventory"},"access":{"type":"object","properties":{"origin":{"oneOf":[{"$ref":"#/components/schemas/AccessProfile"},{"type":"null"}]},"destination":{"oneOf":[{"$ref":"#/components/schemas/AccessProfile"},{"type":"null"}]},"flags":{"type":"array","items":{"type":"string"},"description":"Where what the customer said and what the video shows disagree"}}},"quote":{"oneOf":[{"$ref":"#/components/schemas/Quote"},{"type":"null"}]},"steps":{"oneOf":[{"$ref":"#/components/schemas/PriceSteps"},{"type":"null"}]},"contact":{"$ref":"#/components/schemas/Contact"},"terms":{"$ref":"#/components/schemas/Terms"},"decline_reason":{"type":["string","null"],"description":"Only when status is declined and the mover gave one"},"error":{"type":["string","null"],"description":"Only when status is error"}},"required":["job_id","status","quote_kind","inventory","access","contact","terms"],"description":"Full job state — what the widget polls. Before analysis, inventory is empty and quote is null (or a ballpark)."},"JobSummary":{"type":"object","properties":{"id":{"type":"string","description":"ULID"},"status":{"$ref":"#/components/schemas/JobStatus"},"move_date":{"type":["string","null"],"format":"date","description":"YYYY-MM-DD"},"total_cuft":{"type":["number","null"]},"quote_total_cents":{"type":["integer","null"],"description":"The firm price, once one exists, in integer cents. Currency travels beside it — never assume USD."},"quote_currency":{"type":["string","null"]},"outcome_captured_at":{"type":["string","null"],"format":"date-time"},"created_at":{"type":"string","format":"date-time"},"customer_name":{"type":["string","null"]},"customer_email":{"type":["string","null"]},"customer_phone":{"type":["string","null"]}},"description":"One row of GET /jobs. Sample/demo jobs are never listed."},"Session":{"type":"object","properties":{"tenant":{"type":"object","properties":{"slug":{"type":"string"},"company_name":{"type":"string"},"config":{"type":"object","properties":{},"additionalProperties":true,"description":"Units, currency, region and display switches"},"branding":{"type":"object","properties":{},"additionalProperties":true}}}}},"HomeProfile":{"type":"object","properties":{"dwelling":{"type":"string","description":"Key to send as `dwelling`","example":"2br_apt"},"avg_cuft":{"type":"number"},"typical_crew":{"type":"integer"}},"description":"A home size the ballpark can price. The list is per region."},"CatalogItem":{"type":"object","properties":{"item_key":{"type":"string"},"item_name":{"type":"string"},"room_category":{"type":"string"},"cubic_feet":{"type":"number","description":"Per unit"},"weight_lbs":{"type":"number","description":"Per unit"}}},"UploadTicket":{"description":"One of two shapes. Under 16 MB: `upload` (a single presigned PUT). At or above: `multipart` (a presigned PUT per part; PUT each, collect the ETag response headers, then POST /upload-complete).","oneOf":[{"type":"object","properties":{"job_id":{"type":"string"},"upload":{"type":"object","properties":{"method":{"type":"string","enum":["PUT"]},"url":{"type":"string","description":"Presigned; expires"},"headers":{"type":"object","properties":{"Content-Type":{"type":"string"}},"description":"Send exactly these"},"expires_in":{"type":"integer","description":"Seconds"}}}},"required":["job_id","upload"]},{"type":"object","properties":{"job_id":{"type":"string"},"multipart":{"type":"object","properties":{"upload_id":{"type":"string","description":"Pass back to /upload-complete"},"part_size":{"type":"integer","description":"Bytes per part; the last part may be shorter"},"parts":{"type":"array","items":{"type":"object","properties":{"part_number":{"type":"integer","description":"1-based"},"url":{"type":"string","description":"Presigned PUT for this part"}},"additionalProperties":true}},"expires_in":{"type":"integer","description":"Seconds"}}}},"required":["job_id","multipart"]}]},"DatePrices":{"type":"object","properties":{"job_id":{"type":"string"},"currency":{"type":"string"},"move_date":{"type":["string","null"],"format":"date","description":"The job's current date"},"flat":{"type":"boolean","description":"true = every day prices the same; a UI can hide the calendar"},"days":{"type":"array","items":{"type":"object","properties":{"date":{"type":"string","format":"date","description":"YYYY-MM-DD"},"dow":{"type":"string","description":"Mon…Sun"},"total_cents":{"type":"integer","description":"The firm price on that day, in integer cents. Currency travels beside it — never assume USD."},"is_cheapest":{"type":"boolean"},"is_selected":{"type":"boolean","description":"true on the job's current date"},"is_blocked":{"type":"boolean","description":"The mover marked the day off. Still bookable — they will call first."}}}}},"description":"~14 days from tomorrow (or around the chosen date), each priced by the mover's date rules."},"OutcomeRequest":{"type":"object","properties":{"actual_hours":{"type":"number"},"actual_total_cents":{"type":"integer","description":"What was actually billed, in integer cents. Currency travels beside it — never assume USD."},"actual_crew":{"type":"integer"},"notes":{"type":"string","description":"Up to 1000 chars"}},"description":"At least one of actual_hours, actual_total_cents, actual_crew."},"OutcomeResponse":{"type":"object","properties":{"job_id":{"type":"string"},"outcome_captured_at":{"type":"string","format":"date-time"},"credit_awarded":{"type":"boolean","description":"true on the FIRST report for this job"},"credit_cents":{"type":"integer","description":"Outcome credit minted against the mover's bill (half the survey's billed rate), in integer cents. Currency travels beside it — never assume USD."},"accuracy":{"type":["object","null"],"properties":{"predicted_total_cents":{"type":"integer","description":"What the quote said, in integer cents. Currency travels beside it — never assume USD."},"actual_total_cents":{"type":"integer","description":"What you reported, in integer cents. Currency travels beside it — never assume USD."},"delta_cents":{"type":"integer","description":"actual − predicted, in integer cents. Currency travels beside it — never assume USD."},"delta_pct":{"type":["number","null"],"description":"One decimal"},"predicted_hours":{"type":["number","null"]},"actual_hours":{"type":["number","null"]},"predicted_crew":{"type":["integer","null"]},"actual_crew":{"type":["integer","null"]}},"description":"Present when a total was reported and a quote existed."}}},"WebhookDelivery":{"type":"object","properties":{"id":{"type":"string","description":"Delivery id — pass to /webhooks/deliveries/replay"},"envelope_id":{"type":"string","description":"The `id` inside the body the receiver saw; a replay keeps it, so dedupe still works"},"webhook_id":{"type":"string"},"event":{"type":"string","enum":["job.complete","cube_sheet.ready","job.booked_requested","job.booked","job.declined"]},"job_id":{"type":["string","null"]},"livemode":{"type":"boolean"},"status":{"type":"string","enum":["pending","delivered","failed"]},"attempts":{"type":"integer","description":"1–3"},"last_status":{"type":["integer","null"],"description":"HTTP status of the last attempt; null when it never connected"},"last_error":{"type":["string","null"]},"replayed_from":{"type":["string","null"],"description":"The delivery this one re-sent"},"created_at":{"type":"string","format":"date-time"},"delivered_at":{"type":["string","null"],"format":"date-time"},"body":{"type":"string","description":"The exact JSON that was POSTed — a WebhookEnvelope"}},"required":["id","envelope_id","webhook_id","event","livemode","status","attempts","created_at","body"],"description":"One webhook POST and what became of it. Kept 30 days."},"ApiKey":{"type":"object","properties":{"id":{"type":"string","description":"Key id (ULID) — the handle for rotate and revoke"},"kind":{"type":"string","enum":["publishable","secret"],"description":"pk_ is browser-safe and drives the funnel; sk_ is server-only."},"mode":{"type":"string","enum":["live","test"],"description":"test keys create sandbox jobs: fixed inventory at /submit, no billing, no email, webhooks say livemode:false, and they see only test jobs."},"prefix":{"type":"string","description":"The first 12 characters, for recognising it in a list","example":"sk_live_a1B2"},"label":{"type":["string","null"],"description":"Free text you gave it"},"scopes":{"type":["array","null"],"items":{"type":"string","enum":["quote","jobs:read","jobs:write","outcomes","keys"]},"description":"null = everything its kind allows"},"status":{"type":"string","enum":["active","revoked"]},"created_at":{"type":"string","format":"date-time"},"last_used_at":{"type":["string","null"],"format":"date-time"},"expires_at":{"type":["string","null"],"format":"date-time","description":"Set by rotation: the key works until this instant"},"revoked_at":{"type":["string","null"],"format":"date-time"},"rotated_to":{"type":["string","null"],"description":"The id of the key that replaced it"},"secret":{"type":"string","description":"ONLY in the response that minted or rotated it: the raw key. Store it now; it is never shown again."}},"required":["id","kind","mode","prefix","scopes","status","created_at"]},"Availability":{"type":"object","properties":{"tenant":{"type":"string","description":"The mover's slug"},"policy":{"type":"string","enum":["soft"],"description":"Marked days remain requestable; the mover is warned and confirms by phone. There is no hard block."},"from":{"type":"string","format":"date","description":"First day in the window (never earlier than tomorrow)"},"to":{"type":"string","format":"date","description":"Last day in the window"},"days":{"type":"array","items":{"type":"object","properties":{"date":{"type":"string","format":"date","description":"YYYY-MM-DD"},"dow":{"type":"string","description":"Sun…Sat"},"status":{"type":"string","enum":["open","marked_off"],"description":"marked_off = the mover would rather not, but will still take the request"}}}},"marked_off":{"type":"array","items":{"type":"string","format":"date","description":"YYYY-MM-DD"},"description":"The marked days inside the window, for a quick check"},"note":{"type":"string","description":"Plain-language explanation of the policy, safe to show a customer"}},"required":["tenant","policy","from","to","days","marked_off"],"description":"Which days the mover has marked off, before any job exists. The same list the price calendar's is_blocked and the booking warning read."},"WebhookEnvelope":{"type":"object","properties":{"id":{"type":"string","description":"ULID, unique per delivery attempt set — dedupe on it"},"event":{"type":"string","enum":["job.complete","cube_sheet.ready","job.booked_requested","job.booked","job.declined"]},"created":{"type":"integer","description":"Unix seconds; the `t` in the signature"},"livemode":{"type":"boolean","description":"false when the event comes from a sandbox job (a test key). Same envelope, same signature — branch on this."},"data":{"type":"object","properties":{},"additionalProperties":true,"description":"Per-event payload, documented under each webhook below"}},"required":["id","event","created","livemode","data"],"description":"Every webhook POST body. Signed: `X-ReelQuote-Signature: t=<unix>,v1=HMAC-SHA256(secret, t + \".\" + body)` — verify before trusting, reject a `t` older than a few minutes. Delivered with two quick retries; answer 2xx fast and do the work afterwards."}},"responses":{"400":{"description":"`invalid_request` — A field is missing, malformed or not allowed in this state. `message` says which. `secret_key_in_url` — A secret key was sent as `?k=`. Secret keys go in the Authorization header only.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"401":{"description":"`unauthorized` — No key, an unknown key, or a revoked key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"403":{"description":"`secret_key_required` — The endpoint is server-only; a publishable key cannot reach it. `insufficient_scope` — The key does not carry the scope this call needs (named in `message`). Mint one that does from the keys page or POST /keys.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"404":{"description":"`not_found` — No such job for this key, or no such endpoint.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"409":{"description":"`last_publishable_key` — The only live publishable key cannot be revoked — the widget stops without one. Rotate it instead. `webhook_paused` — That webhook endpoint is paused; turn it back on before replaying a delivery to it. `video_in_flight` — A video is already being fetched for this job (POST /jobs/{id}/video). Poll GET /jobs/{id}. `video_already_supplied` — The job already has a video and has moved past upload. Start a new job for a different walkthrough. `no_rate_card` — The mover has no active rate card, so nothing can be priced yet. `ballpark_not_bookable` — Only a firm quote from a filmed walkthrough can be booked. `already_requested` — The job is already booked or awaiting the mover, so the move date can no longer change here. Contact the mover. `idempotency_in_progress` — A request with the same Idempotency-Key is still being processed. Obey `Retry-After` (1 s) and retry with the same key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"422":{"description":"`idempotency_key_reused` — That Idempotency-Key was already used with a different request body. A key names one request; use a new key for a new job.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"429":{"description":"`rate_limited` — Per-key limit (60/min publishable, 300/min secret) or the daily ballpark cap. Obey `Retry-After`. Carries `Retry-After` in seconds.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"500":{"description":"`internal_error` — Our side. Safe to retry after a pause; nothing was half-applied.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"501":{"description":"`not_implemented` — A documented-but-not-yet-live endpoint.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"502":{"description":"`address_lookup_failed` — The address service did not answer. Retry, or send the address as free text.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}},"503":{"description":"`address_lookup_unavailable` — Address autocomplete is not configured or is down. Send the address as free text. `upload_unavailable` — Video storage is temporarily unavailable. Retry in a few minutes.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Error"}}}}}},"security":[{"bearer":[]}],"paths":{"/session":{"get":{"summary":"Resolve the key to its mover (name, branding, config).","responses":{"200":{"description":"The mover behind this key","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Session"}}}},"401":{"$ref":"#/components/responses/401"},"429":{"$ref":"#/components/responses/429"}},"x-scope":"quote","operationId":"get_session"}},"/home-profiles":{"get":{"summary":"Home sizes for the ballpark step (2br_apt, 3br_house, …).","responses":{"200":{"description":"Sizes the ballpark can price, smallest first","content":{"application/json":{"schema":{"type":"object","properties":{"profiles":{"type":"array","items":{"$ref":"#/components/schemas/HomeProfile"}}}}}}},"401":{"$ref":"#/components/responses/401"},"429":{"$ref":"#/components/responses/429"}},"x-scope":"quote","operationId":"list_home_profiles"}},"/ballpark":{"post":{"summary":"Instant estimate range from home size + addresses. Free; never bookable.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"dwelling":{"type":"string","description":"A `dwelling` from GET /home-profiles","example":"2br_apt"},"move_date":{"type":"string","format":"date","description":"Lets date-based pricing apply. Optional but strongly recommended."},"origin":{"$ref":"#/components/schemas/AddressInput"},"destination":{"$ref":"#/components/schemas/AddressInput"},"distance_miles":{"type":"number","description":"Only used when no route can be found from the addresses"}},"required":["dwelling"]}}}},"responses":{"200":{"description":"Ballpark quote (kind=ballpark, bookable=false, total_cents=null, low_cents/high_cents set)","content":{"application/json":{"schema":{"type":"object","properties":{"quote":{"$ref":"#/components/schemas/Quote"},"next_step":{"type":"object","properties":{"label":{"type":"string"},"reason":{"type":"string"}},"description":"Copy for the button that leads to filming"}}}}}},"400":{"$ref":"#/components/responses/400"},"401":{"$ref":"#/components/responses/401"},"409":{"$ref":"#/components/responses/409"},"429":{"$ref":"#/components/responses/429"}},"x-scope":"quote","operationId":"ballpark_estimate"}},"/jobs":{"post":{"summary":"Start a job (the survey container). Contact details are NOT accepted here — funnel rule. Send `Idempotency-Key` so a retry cannot create a second job.","parameters":[{"name":"Idempotency-Key","in":"header","required":false,"schema":{"type":"string","minLength":1,"maxLength":255,"pattern":"^[\\x21-\\x7E]+$"},"description":"Any unique string per intended job (a UUID, your order id). For 24 hours, the same key with the same body returns the ORIGINAL 201 body with `Idempotent-Replayed: true` and creates nothing; the same key with a different body is a 422 `idempotency_key_reused`; the same key while the first request is still running is a 409 `idempotency_in_progress` with `Retry-After: 1`. Scoped to your account. Omit it and the request is never deduplicated."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"quote_kind":{"type":"string","enum":["ballpark","firm"],"default":"firm","description":"What the job is for. A firm job becomes bookable once filmed."},"dwelling":{"type":"string","description":"What the customer says their home is (any wording; stored beside what the video measures)","example":"2br_apt"},"move_date":{"type":"string","format":"date","description":"YYYY-MM-DD"},"origin":{"$ref":"#/components/schemas/AddressInput"},"destination":{"$ref":"#/components/schemas/AddressInput"}}}}}},"responses":{"201":{"description":"Created — or, with an Idempotency-Key seen before, the original creation replayed. The inferred access profiles come straight back so a UI can ask 'elevator building?' without a second round trip.","headers":{"Idempotent-Replayed":{"schema":{"type":"string","enum":["true","false"]},"description":"Only when Idempotency-Key was sent: true means this body was stored by an earlier request and no new job was created."}},"content":{"application/json":{"schema":{"type":"object","properties":{"job_id":{"type":"string","description":"ULID — the id for every later call"},"status":{"type":"string","enum":["created"]},"origin":{"oneOf":[{"$ref":"#/components/schemas/AccessProfile"},{"type":"null"}]},"destination":{"oneOf":[{"$ref":"#/components/schemas/AccessProfile"},{"type":"null"}]}}}}}},"400":{"$ref":"#/components/responses/400"},"401":{"$ref":"#/components/responses/401"},"409":{"$ref":"#/components/responses/409"},"422":{"$ref":"#/components/responses/422"},"429":{"$ref":"#/components/responses/429"}},"x-scope":"jobs:write","operationId":"create_job"},"get":{"summary":"List jobs for the tenant, newest first. Secret key only. Paged: pass `limit` (1–100, default 25), optional `status`, and `cursor` from the previous page's `next_cursor` (null on the last page; a bad cursor is a 400).","parameters":[{"name":"limit","in":"query","schema":{"type":"integer","minimum":1,"maximum":100,"default":25}},{"name":"status","in":"query","schema":{"$ref":"#/components/schemas/JobStatus"}},{"name":"cursor","in":"query","schema":{"type":"string"},"description":"Opaque; from the previous response's next_cursor."}],"responses":{"200":{"description":"One page of jobs","content":{"application/json":{"schema":{"type":"object","properties":{"jobs":{"type":"array","items":{"$ref":"#/components/schemas/JobSummary"}},"limit":{"type":"integer"},"next_cursor":{"type":["string","null"],"description":"Pass back as `cursor` for the next page; null when there is none."}}}}}},"400":{"$ref":"#/components/responses/400"},"401":{"$ref":"#/components/responses/401"},"403":{"$ref":"#/components/responses/403"},"429":{"$ref":"#/components/responses/429"}},"x-scope":"jobs:read","operationId":"list_jobs"}},"/jobs/{id}":{"get":{"summary":"Full job state: status, inventory, quote, access, terms, move_date.","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"},"description":"The job's ULID"}],"responses":{"200":{"description":"The job. Poll this while status is queued/processing (every few seconds is fine); the first read after `complete` computes and stores the firm quote.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Job"}}}},"401":{"$ref":"#/components/responses/401"},"404":{"$ref":"#/components/responses/404"},"429":{"$ref":"#/components/responses/429"}},"x-scope":"jobs:read","operationId":"get_job"}},"/jobs/{id}/contact":{"post":{"summary":"Store customer email + optional name + move date. Sends NOTHING — required before upload-url.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"email":{"type":"string"},"name":{"type":"string"},"consent":{"type":"boolean","description":"Must be true. Express consent to email about this quote."},"move_date":{"type":["string","null"],"format":"date","description":"Null clears; absent leaves unchanged."}},"required":["email","consent"]}}}},"responses":{"200":{"description":"Stored","content":{"application/json":{"schema":{"type":"object","properties":{"job_id":{"type":"string"},"contact":{"type":"object","properties":{"email":{"type":"string"},"name":{"type":["string","null"]}}},"move_date":{"type":["string","null"],"format":"date","description":"YYYY-MM-DD"}}}}}},"400":{"$ref":"#/components/responses/400"},"401":{"$ref":"#/components/responses/401"},"404":{"$ref":"#/components/responses/404"},"429":{"$ref":"#/components/responses/429"}},"x-scope":"jobs:write","operationId":"set_contact"}},"/jobs/{id}/upload-url":{"post":{"summary":"Presigned upload for the walkthrough video. Requires stored contact email. Body: { content_type, consent, size }. Send the byte size: at or above 16 MB the response carries `multipart` (an upload_id, a part_size and a presigned PUT per part) instead of `upload`, and each part is retried on its own rather than restarting the whole video. Report each part's ETag to /upload-complete. Below the threshold, or if opening a multipart upload fails, the response is a single `upload`.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"content_type":{"type":"string","description":"A video/* MIME type","example":"video/mp4"},"size":{"type":"integer","description":"Byte size of the file. Decides single vs multipart."},"consent":{"type":"boolean","description":"Must be true: the customer agrees the video will be analysed and shared with the mover."}},"required":["content_type","consent"]}}}},"responses":{"200":{"description":"Where to PUT the bytes","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UploadTicket"}}}},"400":{"$ref":"#/components/responses/400"},"401":{"$ref":"#/components/responses/401"},"404":{"$ref":"#/components/responses/404"},"429":{"$ref":"#/components/responses/429"},"503":{"$ref":"#/components/responses/503"}},"x-scope":"jobs:write","operationId":"create_upload_url"}},"/jobs/{id}/upload-complete":{"post":{"summary":"Assemble a chunked upload. Body: { upload_id, parts: [{ part_number, etag }] }. Only after every part has been PUT; then call /submit.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"upload_id":{"type":"string","description":"From the multipart ticket"},"parts":{"type":"array","items":{"type":"object","properties":{"part_number":{"type":"integer","description":"1-based"},"etag":{"type":"string","description":"The ETag response header of that part's PUT"}}},"description":"Every part, in any order"}},"required":["upload_id","parts"]}}}},"responses":{"200":{"description":"Assembled","content":{"application/json":{"schema":{"type":"object","properties":{"job_id":{"type":"string"},"status":{"type":"string","enum":["uploaded"]}}}}}},"400":{"$ref":"#/components/responses/400"},"401":{"$ref":"#/components/responses/401"},"404":{"$ref":"#/components/responses/404"},"429":{"$ref":"#/components/responses/429"}},"x-scope":"jobs:write","operationId":"complete_upload"}},"/jobs/{id}/submit":{"post":{"summary":"Queue the uploaded video for analysis. Poll GET /jobs/{id} for status.","responses":{"202":{"description":"Queued; analysis typically takes 1–4 minutes. Under a test key the job is complete at once (`sandbox: true`) with the fixed inventory, and no upload is required first.","content":{"application/json":{"schema":{"type":"object","properties":{"job_id":{"type":"string"},"status":{"type":"string","enum":["queued","complete"]},"sandbox":{"type":"boolean","description":"Only present (true) on a sandbox job"}}}}}},"400":{"$ref":"#/components/responses/400"},"401":{"$ref":"#/components/responses/401"},"404":{"$ref":"#/components/responses/404"},"429":{"$ref":"#/components/responses/429"}},"x-scope":"jobs:write","operationId":"submit_job"}},"/jobs/{id}/video":{"post":{"summary":"Server-side handoff: give us a public https URL of the walkthrough instead of uploading it from a browser. We fetch it (up to 500 MB, streamed) and the job runs exactly as an upload would — poll GET /jobs/{id}. Needs the stored contact email and consent, like /upload-url. Replaces /upload-url, /upload-complete and /submit for a caller that already holds the file.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"url":{"type":"string","description":"Public https URL that serves the video (a signed cloud-storage link is ideal). Private hosts and http are refused."},"content_type":{"type":"string","description":"A video/* MIME type when you know it; otherwise the host's Content-Type must be video/*","example":"video/quicktime"},"consent":{"type":"boolean","description":"Must be true: the customer agrees the video will be analysed and shared with the mover."}},"required":["url","consent"]}}}},"responses":{"202":{"description":"Accepted; the fetch runs in the background. Status goes uploading → processing → complete (or error with a plain-language reason if the URL did not serve a video). Under a test key the job completes at once with the fixed inventory.","content":{"application/json":{"schema":{"type":"object","properties":{"job_id":{"type":"string"},"status":{"type":"string","enum":["uploading","complete"]},"source":{"type":"string","enum":["url"],"description":"Absent on a sandbox job"},"sandbox":{"type":"boolean","description":"Only present (true) on a sandbox job"}}}}}},"400":{"$ref":"#/components/responses/400"},"401":{"$ref":"#/components/responses/401"},"404":{"$ref":"#/components/responses/404"},"409":{"$ref":"#/components/responses/409"},"429":{"$ref":"#/components/responses/429"}},"x-scope":"jobs:write","operationId":"hand_off_video"}},"/jobs/{id}/access":{"post":{"summary":"Confirm floors / elevator / stairs / carry distance for both ends.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"origin":{"$ref":"#/components/schemas/AccessAnswers"},"destination":{"$ref":"#/components/schemas/AccessAnswers"},"move_date":{"type":["string","null"],"format":"date","description":"YYYY-MM-DD"}}}}}},"responses":{"200":{"description":"Re-priced","content":{"application/json":{"schema":{"type":"object","properties":{"job_id":{"type":"string"},"access":{"type":"object","properties":{"origin":{"oneOf":[{"$ref":"#/components/schemas/AccessProfile"},{"type":"null"}]},"destination":{"oneOf":[{"$ref":"#/components/schemas/AccessProfile"},{"type":"null"}]},"flags":{"type":"array","items":{"type":"string"}}}},"quote":{"oneOf":[{"$ref":"#/components/schemas/Quote"},{"type":"null"}]},"steps":{"oneOf":[{"$ref":"#/components/schemas/PriceSteps"},{"type":"null"}]},"terms":{"$ref":"#/components/schemas/Terms"}}}}}},"400":{"$ref":"#/components/responses/400"},"401":{"$ref":"#/components/responses/401"},"404":{"$ref":"#/components/responses/404"},"429":{"$ref":"#/components/responses/429"}},"x-scope":"jobs:write","operationId":"confirm_access"}},"/jobs/{id}/items":{"post":{"summary":"Edit inventory (quantity, exclude, add by item_key). Response carries the re-priced quote.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"items":{"type":"array","items":{"oneOf":[{"type":"object","properties":{"id":{"type":"string","description":"An existing row's id"},"quantity":{"type":"integer","description":"0 allowed"},"excluded":{"type":"boolean","description":"true = staying. Rows are never deleted."},"notes":{"type":"string","description":"Up to 500 chars"}},"required":["id"],"description":"Edit a row"},{"type":"object","properties":{"item_key":{"type":"string","description":"From GET /catalog"},"quantity":{"type":"integer","description":"Default 1"},"room":{"type":"string","description":"Default 'Added by customer'"},"notes":{"type":"string"}},"required":["item_key"],"description":"Add a row (source becomes customer_added)"}]},"description":"Non-empty. Any edit clears the customer's approval and the e-signature."}},"required":["items"]}}}},"responses":{"200":{"description":"The inventory block exactly as GET /jobs/{id} returns it, plus the re-priced quote","content":{"application/json":{"schema":{"type":"object","properties":{"job_id":{"type":"string"},"inventory":{"$ref":"#/components/schemas/Inventory"},"quote":{"oneOf":[{"$ref":"#/components/schemas/Quote"},{"type":"null"}]},"steps":{"oneOf":[{"$ref":"#/components/schemas/PriceSteps"},{"type":"null"}]},"terms":{"$ref":"#/components/schemas/Terms"}}}}}},"400":{"$ref":"#/components/responses/400"},"401":{"$ref":"#/components/responses/401"},"404":{"$ref":"#/components/responses/404"},"429":{"$ref":"#/components/responses/429"}},"x-scope":"jobs:write","operationId":"edit_items"}},"/jobs/{id}/approve":{"post":{"summary":"Customer sign-off on the inventory, plus free-text notes.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"approved":{"type":"boolean","description":"Default true. false withdraws the sign-off."},"notes":{"type":"string","description":"Up to 2000 chars; absent leaves unchanged"}}}}}},"responses":{"200":{"description":"Stamped","content":{"application/json":{"schema":{"type":"object","properties":{"job_id":{"type":"string"},"inventory_approved_at":{"type":["string","null"],"format":"date-time"},"customer_notes":{"type":["string","null"]}}}}}},"400":{"$ref":"#/components/responses/400"},"401":{"$ref":"#/components/responses/401"},"404":{"$ref":"#/components/responses/404"},"429":{"$ref":"#/components/responses/429"}},"x-scope":"jobs:write","operationId":"approve_inventory"}},"/jobs/{id}/gaps":{"post":{"summary":"Record the customer's answer to the capture-gap prompts (inventory.gaps on GET /jobs/{id}). Body: { acknowledged: true } stamps \"nothing is missing\"; false clears it. The prompts themselves are recomputed from the current inventory on every read, so fixing a gap removes its prompt without this call.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"acknowledged":{"type":"boolean","description":"Default true"}}}}}},"responses":{"200":{"description":"Stamped","content":{"application/json":{"schema":{"type":"object","properties":{"job_id":{"type":"string"},"gaps_acknowledged_at":{"type":["string","null"],"format":"date-time"}}}}}},"401":{"$ref":"#/components/responses/401"},"404":{"$ref":"#/components/responses/404"},"429":{"$ref":"#/components/responses/429"}},"x-scope":"jobs:write","operationId":"acknowledge_gaps"}},"/jobs/{id}/send-link":{"post":{"summary":"Email the customer their resume link. Only on explicit request.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"email":{"type":"string"},"name":{"type":"string"},"consent":{"type":"boolean","description":"Must be true"}},"required":["email","consent"]}}}},"responses":{"200":{"description":"Sent (queued). On a sandbox job nothing is sent: `sent: false, sandbox: true`.","content":{"application/json":{"schema":{"type":"object","properties":{"job_id":{"type":"string"},"sent_to":{"type":"string"},"sent":{"type":"boolean","description":"Only present (false) on a sandbox job"},"sandbox":{"type":"boolean","description":"Only present (true) on a sandbox job"}}}}}},"400":{"$ref":"#/components/responses/400"},"401":{"$ref":"#/components/responses/401"},"404":{"$ref":"#/components/responses/404"},"429":{"$ref":"#/components/responses/429"}},"x-scope":"jobs:write","operationId":"send_resume_link"}},"/jobs/{id}/book":{"post":{"summary":"Request the booking. Idempotent per job; requires approve:true (types-name-as-signature approval of the inventory + quote, stored with a quote snapshot) and accept_terms when the mover has terms.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"name":{"type":"string","description":"The submitted name is the signature on the approval."},"email":{"type":"string"},"phone":{"type":"string"},"approve":{"type":"boolean","description":"Must be true: approves the inventory as complete and this quote. Cleared if the inventory is edited afterwards."},"accept_terms":{"type":"boolean","description":"Required (true) when the job's `terms.terms` is non-null"}},"required":["name","email","approve"]}}}},"responses":{"200":{"description":"Requested — or, on a repeat, the existing request with already_requested:true and nothing re-sent","content":{"application/json":{"schema":{"type":"object","properties":{"job_id":{"type":"string"},"status":{"type":"string","enum":["booked_requested","booked"]},"quote":{"$ref":"#/components/schemas/Quote"},"already_requested":{"type":"boolean","description":"Only present (true) on a repeat call"}}}}}},"400":{"$ref":"#/components/responses/400"},"401":{"$ref":"#/components/responses/401"},"404":{"$ref":"#/components/responses/404"},"409":{"$ref":"#/components/responses/409"},"429":{"$ref":"#/components/responses/429"}},"x-scope":"jobs:write","operationId":"request_booking"}},"/jobs/{id}/outcome":{"post":{"summary":"Report actuals (hours, final cents, crew). Secret key only.","requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OutcomeRequest"}}}},"responses":{"200":{"description":"Recorded; credit minted on the first report","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OutcomeResponse"}}}},"400":{"$ref":"#/components/responses/400"},"401":{"$ref":"#/components/responses/401"},"403":{"$ref":"#/components/responses/403"},"404":{"$ref":"#/components/responses/404"},"429":{"$ref":"#/components/responses/429"}},"x-scope":"outcomes","operationId":"report_outcome"}},"/jobs/{id}/cube-sheet":{"get":{"summary":"Itemized cube sheet as CSV (Excel-ready): rooms, volumes, weights, exclusions. Secret key only.","responses":{"200":{"description":"text/csv","content":{"text/csv":{"schema":{"type":"string"}}}},"401":{"$ref":"#/components/responses/401"},"403":{"$ref":"#/components/responses/403"},"404":{"$ref":"#/components/responses/404"},"429":{"$ref":"#/components/responses/429"}},"x-scope":"jobs:read","operationId":"get_cube_sheet_csv"}},"/jobs/{id}/date-prices":{"get":{"summary":"This job's quote priced across a ~2-week window from the mover's date rules. flat=true means every day prices the same.","responses":{"200":{"description":"The window","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DatePrices"}}}},"400":{"$ref":"#/components/responses/400"},"401":{"$ref":"#/components/responses/401"},"404":{"$ref":"#/components/responses/404"},"429":{"$ref":"#/components/responses/429"}},"x-scope":"jobs:read","operationId":"get_date_prices"}},"/jobs/{id}/date":{"post":{"summary":"Set or clear the move date; invalidates and recomputes the quote. Refused after a booking is requested.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"date":{"type":["string","null"],"format":"date","description":"YYYY-MM-DD, or null to clear."}}}}}},"responses":{"200":{"description":"Re-priced","content":{"application/json":{"schema":{"type":"object","properties":{"job_id":{"type":"string"},"move_date":{"type":["string","null"],"format":"date","description":"YYYY-MM-DD"},"status":{"$ref":"#/components/schemas/JobStatus"},"quote":{"oneOf":[{"$ref":"#/components/schemas/Quote"},{"type":"null"}]},"steps":{"oneOf":[{"$ref":"#/components/schemas/PriceSteps"},{"type":"null"}]}},"additionalProperties":true}}}},"400":{"$ref":"#/components/responses/400"},"401":{"$ref":"#/components/responses/401"},"404":{"$ref":"#/components/responses/404"},"409":{"$ref":"#/components/responses/409"},"429":{"$ref":"#/components/responses/429"}},"x-scope":"jobs:write","operationId":"set_move_date"}},"/quote":{"post":{"summary":"Direct pricing for agents: items or total_cuft in, firm quote out. Secret key only.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"total_cuft":{"type":"number","description":"Either this…"},"items":{"type":"array","items":{"type":"object","properties":{"item_key":{"type":"string"},"quantity":{"type":"integer"}}},"description":"…or this, priced from the catalog"},"total_weight_lbs":{"type":"number","description":"Optional with total_cuft; derived from items otherwise"},"distance_miles":{"type":"number"},"move_date":{"type":"string","format":"date","description":"YYYY-MM-DD"},"origin":{"$ref":"#/components/schemas/AddressInput"},"destination":{"$ref":"#/components/schemas/AddressInput"},"rate_card_id":{"type":"string","description":"A specific card of this mover's; default the active one"}}}}}},"responses":{"200":{"description":"Firm quote","content":{"application/json":{"schema":{"type":"object","properties":{"quote":{"$ref":"#/components/schemas/Quote"}}}}}},"400":{"$ref":"#/components/responses/400"},"401":{"$ref":"#/components/responses/401"},"403":{"$ref":"#/components/responses/403"},"409":{"$ref":"#/components/responses/409"},"429":{"$ref":"#/components/responses/429"}},"x-scope":"quote","operationId":"quote_inventory"}},"/availability":{"get":{"summary":"Which days the mover has marked off, before a job exists. Soft: a marked day can still be requested — the mover confirms and calls first — so steer to an open day when the customer has a choice.","parameters":[{"name":"from","in":"query","schema":{"type":"string","format":"date"},"description":"First day, YYYY-MM-DD. Default tomorrow; earlier dates clamp to tomorrow."},{"name":"days","in":"query","schema":{"type":"integer","minimum":1,"maximum":90,"default":30},"description":"Window length; values above 90 are capped."}],"responses":{"200":{"description":"The window, one entry per day","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Availability"}}}},"400":{"$ref":"#/components/responses/400"},"401":{"$ref":"#/components/responses/401"},"429":{"$ref":"#/components/responses/429"}},"x-scope":"quote","operationId":"get_availability"}},"/keys":{"get":{"summary":"List the account's API keys — live and test, active and revoked. Never a raw secret. Secret key with the `keys` scope.","responses":{"200":{"description":"Every key","content":{"application/json":{"schema":{"type":"object","properties":{"keys":{"type":"array","items":{"$ref":"#/components/schemas/ApiKey"}}}}}}},"401":{"$ref":"#/components/responses/401"},"403":{"$ref":"#/components/responses/403"},"429":{"$ref":"#/components/responses/429"}},"x-scope":"keys","operationId":"list_keys"},"post":{"summary":"Mint a key. The raw secret is in THIS response only. A key can never mint one broader than itself; a test key mints only test keys; a live secret key may mint the sandbox pair.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"kind":{"type":"string","enum":["publishable","secret"],"default":"secret"},"mode":{"type":"string","enum":["live","test"],"description":"Default: the calling key's mode."},"scopes":{"type":["array","null"],"items":{"type":"string","enum":["quote","jobs:read","jobs:write","outcomes","keys"]},"description":"null (default) = everything the kind allows"},"label":{"type":"string","description":"Up to 80 chars, e.g. 'CRM sync'"}}}}}},"responses":{"201":{"description":"Minted. `key.secret` is shown once.","content":{"application/json":{"schema":{"type":"object","properties":{"key":{"$ref":"#/components/schemas/ApiKey"}}}}}},"400":{"$ref":"#/components/responses/400"},"401":{"$ref":"#/components/responses/401"},"403":{"$ref":"#/components/responses/403"},"429":{"$ref":"#/components/responses/429"}},"x-scope":"keys","operationId":"create_key"}},"/keys/rotate":{"post":{"summary":"Replace a key with its twin (same kind, mode, scopes, label). The old key keeps working until `replaced.expires_at` so a deploy can swap values without a gap. Needs the `keys` scope even to rotate itself — rotate a scoped key with a full key or from the dashboard.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string","description":"Which key. Default: the key making this call."},"grace_hours":{"type":"integer","minimum":0,"maximum":72,"default":24,"description":"0 revokes the old key immediately."}}}}}},"responses":{"200":{"description":"The replacement, shown once, and when the old one stops","content":{"application/json":{"schema":{"type":"object","properties":{"key":{"$ref":"#/components/schemas/ApiKey"},"replaced":{"type":"object","properties":{"id":{"type":"string"},"prefix":{"type":"string"},"expires_at":{"type":"string","format":"date-time"}}}}}}}},"400":{"$ref":"#/components/responses/400"},"401":{"$ref":"#/components/responses/401"},"403":{"$ref":"#/components/responses/403"},"404":{"$ref":"#/components/responses/404"},"429":{"$ref":"#/components/responses/429"}},"x-scope":"keys","operationId":"rotate_key"}},"/keys/revoke":{"post":{"summary":"Revoke a key immediately. The only live publishable key is refused (409) — rotate it instead.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string"}},"required":["id"]}}}},"responses":{"200":{"description":"Revoked","content":{"application/json":{"schema":{"type":"object","properties":{"revoked":{"type":"object","properties":{"id":{"type":"string"},"prefix":{"type":"string"},"revoked_at":{"type":"string","format":"date-time"}}}}}}}},"400":{"$ref":"#/components/responses/400"},"401":{"$ref":"#/components/responses/401"},"403":{"$ref":"#/components/responses/403"},"404":{"$ref":"#/components/responses/404"},"409":{"$ref":"#/components/responses/409"},"429":{"$ref":"#/components/responses/429"}},"x-scope":"keys","operationId":"revoke_key"}},"/webhooks/deliveries":{"get":{"summary":"The delivery log: every webhook POST for 30 days, newest first, with its outcome and the exact body sent. Secret key with the `keys` scope.","parameters":[{"name":"webhook_id","in":"query","schema":{"type":"string"},"description":"One endpoint only"},{"name":"job_id","in":"query","schema":{"type":"string"},"description":"Everything sent about one job"},{"name":"limit","in":"query","schema":{"type":"integer","minimum":1,"maximum":100,"default":25}},{"name":"before","in":"query","schema":{"type":"string","format":"date-time"},"description":"The previous page's next_before"}],"responses":{"200":{"description":"One page","content":{"application/json":{"schema":{"type":"object","properties":{"deliveries":{"type":"array","items":{"$ref":"#/components/schemas/WebhookDelivery"}},"limit":{"type":"integer"},"next_before":{"type":["string","null"],"format":"date-time","description":"Pass as `before` for the next page; null on the last"}}}}}},"400":{"$ref":"#/components/responses/400"},"401":{"$ref":"#/components/responses/401"},"403":{"$ref":"#/components/responses/403"},"429":{"$ref":"#/components/responses/429"}},"x-scope":"keys","operationId":"list_webhook_deliveries"}},"/webhooks/deliveries/replay":{"post":{"summary":"Re-send one delivery to its endpoint: the same body byte for byte (same envelope id, so your dedupe still works) under a fresh signature, with the usual retries. Returns the replay's own delivery record.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string","description":"A delivery id from the log"}},"required":["id"]}}}},"responses":{"200":{"description":"The replay, recorded and pointing at the original","content":{"application/json":{"schema":{"type":"object","properties":{"delivery":{"$ref":"#/components/schemas/WebhookDelivery"}}}}}},"400":{"$ref":"#/components/responses/400"},"401":{"$ref":"#/components/responses/401"},"403":{"$ref":"#/components/responses/403"},"404":{"$ref":"#/components/responses/404"},"409":{"$ref":"#/components/responses/409"},"429":{"$ref":"#/components/responses/429"}},"x-scope":"keys","operationId":"replay_webhook_delivery"}},"/catalog":{"get":{"summary":"Search the cube-sheet catalog (?q=sofa).","parameters":[{"name":"q","in":"query","required":true,"schema":{"type":"string","minLength":2},"description":"Name, synonym or key fragment"}],"responses":{"200":{"description":"Up to 12 matches, name matches first","content":{"application/json":{"schema":{"type":"object","properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/CatalogItem"}}}}}}},"401":{"$ref":"#/components/responses/401"},"429":{"$ref":"#/components/responses/429"}},"x-scope":"quote","operationId":"search_catalog"}},"/places/suggest":{"post":{"summary":"Address autocomplete (proxied; send session_token).","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"input":{"type":"string","description":"What the customer has typed so far"},"session_token":{"type":"string","description":"Any opaque string, kept for the whole typing session and the resolve call"}}}}}},"responses":{"200":{"description":"Suggestions","content":{"application/json":{"schema":{"type":"object","properties":{},"additionalProperties":true}}}},"400":{"$ref":"#/components/responses/400"},"401":{"$ref":"#/components/responses/401"},"429":{"$ref":"#/components/responses/429"},"502":{"$ref":"#/components/responses/502"},"503":{"$ref":"#/components/responses/503"}},"x-scope":"quote","operationId":"suggest_addresses"}},"/places/resolve":{"post":{"summary":"Resolve a suggestion to a full address (same session_token).","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"place_id":{"type":"string"},"session_token":{"type":"string"}}}}}},"responses":{"200":{"description":"The resolved address, ready to send as an AddressInput","content":{"application/json":{"schema":{"type":"object","properties":{},"additionalProperties":true}}}},"400":{"$ref":"#/components/responses/400"},"401":{"$ref":"#/components/responses/401"},"429":{"$ref":"#/components/responses/429"},"502":{"$ref":"#/components/responses/502"},"503":{"$ref":"#/components/responses/503"}},"x-scope":"quote","operationId":"resolve_address"}}},"webhooks":{"job.complete":{"post":{"summary":"Inventory analysed and priced.","description":"Signed: `X-ReelQuote-Signature: t=<unix>,v1=HMAC-SHA256(secret, t + \".\" + body)`. Verify before trusting. Delivered with two quick retries.","requestBody":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/WebhookEnvelope"},{"type":"object","properties":{"event":{"type":"string","enum":["job.complete"]},"data":{"type":"object","properties":{"job_id":{"type":"string"},"status":{"$ref":"#/components/schemas/JobStatus"},"move_date":{"type":["string","null"],"format":"date","description":"YYYY-MM-DD"},"customer":{"type":["object","null"],"properties":{"name":{"type":"string"},"email":{"type":["string","null"]},"phone":{"type":["string","null"]}},"description":"null until the customer has given contact details"},"item_count":{"type":"integer"},"total_cuft":{"type":"number"},"quote_total_cents":{"type":"integer","description":"The firm price, in integer cents. Currency travels beside it — never assume USD."},"currency":{"type":"string"}}}}}]}}}},"responses":{"2XX":{"description":"Acknowledged. Answer fast; do the work afterwards."},"default":{"description":"Anything else is retried twice, then recorded as the endpoint's last_error."}}}},"cube_sheet.ready":{"post":{"summary":"Itemized cube sheet ready: rooms/items with volumes, weights and exclusions inline, plus the CSV URL.","description":"Signed: `X-ReelQuote-Signature: t=<unix>,v1=HMAC-SHA256(secret, t + \".\" + body)`. Verify before trusting. Delivered with two quick retries.","requestBody":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/WebhookEnvelope"},{"type":"object","properties":{"event":{"type":"string","enum":["cube_sheet.ready"]},"data":{"type":"object","properties":{"job_id":{"type":"string"},"customer":{"type":["object","null"],"properties":{"name":{"type":"string"},"email":{"type":["string","null"]},"phone":{"type":["string","null"]}},"description":"null until the customer has given contact details"},"move_date":{"type":["string","null"],"format":"date","description":"YYYY-MM-DD"},"totals":{"type":"object","properties":{},"additionalProperties":true,"description":"item_count, total_quantity, total_cuft, total_weight_lbs, excluded counts"},"rooms":{"type":"array","items":{"type":"object","properties":{"room":{"type":"string"},"items":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string"},"item_key":{"type":"string"},"quantity":{"type":"integer"},"cuft_each":{"type":"number"},"weight_lbs_each":{"type":"number"},"moving":{"type":"boolean","description":"false = the customer said it stays"},"fragile":{"type":"boolean"},"requires_disassembly":{"type":"boolean"},"two_person":{"type":"boolean"},"special_handling":{"type":"boolean"},"notes":{"type":["string","null"]}}}}}}},"csv_url":{"type":["string","null"],"description":"GET with a secret key for the same sheet as CSV"}}}}}]}}}},"responses":{"2XX":{"description":"Acknowledged. Answer fast; do the work afterwards."},"default":{"description":"Anything else is retried twice, then recorded as the endpoint's last_error."}}}},"job.booked_requested":{"post":{"summary":"Customer asked to book.","description":"Signed: `X-ReelQuote-Signature: t=<unix>,v1=HMAC-SHA256(secret, t + \".\" + body)`. Verify before trusting. Delivered with two quick retries.","requestBody":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/WebhookEnvelope"},{"type":"object","properties":{"event":{"type":"string","enum":["job.booked_requested"]},"data":{"type":"object","properties":{"job_id":{"type":"string"},"customer":{"type":"object","properties":{"name":{"type":"string"},"email":{"type":"string"},"phone":{"type":["string","null"]}}},"quote_total_cents":{"type":"integer","description":"The price the customer approved, in integer cents. Currency travels beside it — never assume USD."},"currency":{"type":"string"},"move_date":{"type":["string","null"],"format":"date","description":"YYYY-MM-DD"},"requested_day_marked_off":{"type":"boolean","description":"true when the mover had marked that day off — call the customer before confirming"}}}}}]}}}},"responses":{"2XX":{"description":"Acknowledged. Answer fast; do the work afterwards."},"default":{"description":"Anything else is retried twice, then recorded as the endpoint's last_error."}}}},"job.booked":{"post":{"summary":"Mover confirmed the booking.","description":"Signed: `X-ReelQuote-Signature: t=<unix>,v1=HMAC-SHA256(secret, t + \".\" + body)`. Verify before trusting. Delivered with two quick retries.","requestBody":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/WebhookEnvelope"},{"type":"object","properties":{"event":{"type":"string","enum":["job.booked"]},"data":{"type":"object","properties":{"job_id":{"type":"string"},"move_date":{"type":["string","null"],"format":"date","description":"YYYY-MM-DD"},"quote_total_cents":{"type":"integer","description":"The confirmed price, in integer cents. Currency travels beside it — never assume USD."},"currency":{"type":"string"}}}}}]}}}},"responses":{"2XX":{"description":"Acknowledged. Answer fast; do the work afterwards."},"default":{"description":"Anything else is retried twice, then recorded as the endpoint's last_error."}}}},"job.declined":{"post":{"summary":"Mover turned the booking request down. Carries `reason` when they gave one.","description":"Signed: `X-ReelQuote-Signature: t=<unix>,v1=HMAC-SHA256(secret, t + \".\" + body)`. Verify before trusting. Delivered with two quick retries.","requestBody":{"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/WebhookEnvelope"},{"type":"object","properties":{"event":{"type":"string","enum":["job.declined"]},"data":{"type":"object","properties":{"job_id":{"type":"string"},"move_date":{"type":["string","null"],"format":"date","description":"YYYY-MM-DD"},"reason":{"type":["string","null"],"description":"The mover's words, when they gave any"}}}}}]}}}},"responses":{"2XX":{"description":"Acknowledged. Answer fast; do the work afterwards."},"default":{"description":"Anything else is retried twice, then recorded as the endpoint's last_error."}}}}}}