Base URL: https://api.roofquery.com. Authenticate with Authorization: Bearer <your key> — except the file routes (report and storm report files), which take no key.
Copies this whole page as Markdown, so you can paste it into an assistant and have it write the integration. Nothing secret goes on the clipboard — your keys aren't included.
New here? Make a sandbox key and follow the Quick start: a free test order, delivered with its webhooks, in about a minute.
| Method | Path | Purpose |
|---|---|---|
| — | Quick start | Keys, sandbox versus live, and your first order in three calls |
| GET | /api/v1/reports/sandbox/scenarios | Every test address, roof and storm, by report type, and what each does |
One API key covers every product, every product draws on the same balance, and one set of webhook endpoints covers every report, Storm Reports included (they also answer in the response). These don't change as products are added.
| Method | Path | Purpose |
|---|---|---|
| GET | /api/v1/identity | Who the key belongs to, and whether it can order |
| GET | /api/v1/balance | Available credit and per-product prices |
| GET | /api/v1/webhooks | List your endpoints |
| GET | /api/v1/branding/profiles | Saved brands for the contractors you order for |
| POST | /api/v1/branding/profiles | Create a profile |
| GET | /api/v1/branding/profiles/:id | One profile |
| PATCH | /api/v1/branding/profiles/:id | Change a profile |
| DELETE | /api/v1/branding/profiles/:id | Delete a profile and its logo |
| PUT | /api/v1/branding/profiles/:id/logo | Upload the profile's logo |
| DELETE | /api/v1/branding/profiles/:id/logo | Remove the logo, keep the profile |
Your own defaults are not on this list. The account's company name,
contact block, colours and logo are set in the portal under
Branding. What the API takes is
everything to do with the contractors you order for: a
profile per contractor,
logo included, named as brand when you
place an order. That order keeps
the brand for good. See
White labeling.
| Method | Path | Purpose |
|---|---|---|
| GET | /api/v1/reports/pricing | Report products and prices |
| POST | /api/v1/reports/orders | Place an order |
| GET | /api/v1/reports/orders/:id | Order status and measurements |
| GET | /api/v1/reports/orders/:id/status | The same order, plus a resolved statusDetail block |
| POST | /api/v1/reports/orders/:id/esx | Add the Xactimate ESX to an order already placed |
| GET | /api/v1/reports/orders/:id/pdf | Branded PDF (rendered per request) |
| GET | /api/v1/reports/orders/:id/xml | The measurement document as XML |
| GET | /api/v1/reports/orders/:id/esx | The same report as an Xactimate .esx, on orders that include it |
| GET | /api/v1/reports/orders/:id/diagrams | Every diagram available for the order, already addressed |
| GET | /api/v1/reports/orders/:id/diagrams/:structure/:type | One roof drawing, as SVG or PNG |
| POST | /api/v1/reports/orders/:id/revisions | Request a revision on a completed report |
| GET | /api/v1/reports/orders/:id/revisions | Revision history for one order |
| GET | /api/v1/reports/revision-options | Valid revision topics and rules |
| Method | Path | Purpose |
|---|---|---|
| POST | /api/v1/reports/orders | Order one, with "reportType": "rapid_squares" |
| GET | /api/v1/reports/orders/:id/photo | The photo the outlines were traced on (no key) |
| Method | Path | Purpose |
|---|---|---|
| — | Storm Reports | What a storm report is, its price and coverage |
| POST | /api/v1/reports/orders | Order one, with "reportType": "storm"; the response is the report |
| GET | /api/v1/reports/orders/:id | The report, with this read's date of loss and notes |
| — | Storm report data | Every field of a storm report, damage chance and confidence |
| GET | /api/v1/reports/orders/:id/pdf | One of the five PDFs, by template (no key) |
| GET | /api/v1/reports/orders/:id/map.png | The map on its own (no key) |
| Topic | What it covers |
|---|---|
| Sharing a report | The file routes take no key — what that means for a link |
| Order lifecycle | Every status and event, and which statuses are terminal |
| Report data | The totals and structures[] field reference |
| White labeling | Your brand, or a contractor's, on the PDF, chosen when ordering |
| Revisions | When a report can be corrected, and what it costs |
| Errors | Every error code the API returns |
From no key to a delivered sandbox report in about a minute, without spending anything. The same calls then work live: only the key changes.
Keys are made in the dashboard under API keys. There are two kinds, and the prefix is the whole difference between them. The base URL, the endpoints and the request bodies are identical, and nothing in a request can switch a key to the other environment.
| Key | What an order does |
|---|---|
rq_test_sk_… | Runs a sandbox timeline: free, nothing is measured, a recorded real report is delivered, and your webhooks fire as they would live. |
rq_live_sk_… | A real order, measured by a technician and paid from your balance. |
One key covers every product. Send it as Authorization: Bearer <key> on every
call except the file routes, which take none.
Add the URL that should hear about orders in the dashboard under
Webhooks. One endpoint receives every status
change of every order, sandbox orders included, so you can build your handler against sandbox.
Each delivery is the same body as GET
/reports/orders/:id.
1. Check the key. env is test for a sandbox key.
curl -H "Authorization: Bearer rq_test_sk_..." \
"https://api.roofquery.com/api/v1/identity"
2. Order a report for the sandbox's happy_path address. The response is
201 with the order in processing.
curl -X POST "https://api.roofquery.com/api/v1/reports/orders" \
-H "Authorization: Bearer rq_test_sk_..." \
-H "Content-Type: application/json" \
-d '{
"country": "US",
"street": "6515 Turnbridge Pl",
"city": "Prospect",
"state": "KY",
"postalCode": "40059",
"latitude": 38.3526391,
"longitude": -85.6208045,
"scope": "primary_and_garage",
"esx": true
}'
3. Get the report. About 20 seconds later your endpoint receives a webhook with
"status": "completed", carrying the measurements and the file links. Or read the order:
curl -H "Authorization: Bearer rq_test_sk_..." \
"https://api.roofquery.com/api/v1/reports/orders/<orderId from step 2>"
totals and structures[] hold the measurements, and files[]
links the PDF, XML
and, because step 2 sent "esx": true, the ESX.
Leave esx out and your account's default decides. To go live, swap in an
rq_live_sk_ key and a real address. Nothing else changes.
- Sandbox: the other seven test addresses, including holds, cancellations and failed orders.
- Place an order: every field, and retrying safely with
Idempotency-Key. - Order lifecycle: every status your webhook handler will see.
- White labeling: your brand, or a contractor's, on the PDF.
- Errors: every code, and what to do about it.
Lists the sandbox test addresses for every report type: residential, commercial, Rapid Squares and
Storm Reports. Order one with an rq_test_sk_* key and it runs that address's scenario, so
you can test every outcome your code has to handle before a live order.
- Free. A sandbox order never draws balance, and needs no card. Adding an ESX to a sandbox order is free too.
- The key decides. A sandbox order is the same request as a live one, with an
rq_test_sk_*key instead ofrq_live_sk_*. No header or body field forces a scenario, and no address turns a sandbox order into a real one. - The address picks the scenario, case-insensitive, or by coordinates within about 110 m. The table below lists every address and what it does for each report type.
- Real webhooks. Every status change of a roof report is sent to your webhook endpoints, the same body a live order sends. Storm Reports answer in the response, as they do live.
| Report type | Scenario | Address | What happens |
|---|---|---|---|
| Residential, Rapid Squares | happy_path | 6515 Turnbridge Pl, Prospect, KY 40059 | processing at 0 s, in-progress at 10 s, completed at 20 s. One structure: a 33-facet hip roof with dormers, hips, valleys and a pitch change. |
| Residential | happy_path_2_structures | 725 Pleasant Ave, St Paul Park, MN 55071 | Same timeline. Two structures: a house with an attached wing, and a detached shed. |
| Residential, Rapid Squares | happy_path_4_structures | 401 Garrison St, Bulpitt, IL 62517 | Same timeline. Four structures: a house and three outbuildings. |
| Commercial | happy_path_commercial | 6101 E Lombard St, Baltimore, MD 21224 | Same timeline. A low-slope commercial roof with parapet walls. |
| Residential, Rapid Squares | on_hold | 12440 S Nimitz Dr, Galveston, IN 46932 | processing at 0 s, in-progress at 10 s, on-hold at 20 s, in-progress again at 50 s, completed at 60 s. A gable roof at three pitches, on a house and a detached shed. |
| Residential, Commercial, Rapid Squares | cancelled | 600 N Michigan Ave, Chicago, IL 60611, and any other address | processing at 0 s, in-progress at 10 s, cancelled at 20 s. Nothing is delivered. |
| Residential, Commercial, Rapid Squares | payment_failed | 400 5th Ave, New York, NY 10018 | The order call itself returns 402 INSUFFICIENT_CREDITS. No order is created and no webhook fires. |
| Residential, Commercial, Rapid Squares | order_failed | 500 Hollywood Blvd, Los Angeles, CA 90028 | The order call itself returns 502 ORDER_FAILED. No order is created and no webhook fires. |
| Storm | storm_busy | 3208 Riviera Dr, Norman, OK 73072, and any other address | A busy history: large hail, 75–130 mph wind, tornadoes. Try dateOfLoss: "2025-05-25". Any other address returns this report, with your address on it. |
| Storm | storm_hail | 1816 Copper Creek Dr, Plano, TX 75075 | Hail-led: 3" hail within 1 mile |
| Storm | storm_wind | 112 Regency Pkwy, Omaha, NE 68114 | Wind-led: severe wind on 12 days; the owner is a company |
| Storm | storm_canada | 237 8 Ave SE, Calgary, AB T2G 5C3 | Canada: radar hail and stations only, with the coverage note |
| Storm | storm_none | 157151 US-101, Forks, WA 98331 (Kalaloch Lodge) | Nothing recorded: the free report (billing.reason: "no_storm_data", amount: 0) |
| Storm | payment_failed | 400 5th Ave, New York, NY 10018 | 402 INSUFFICIENT_CREDITS, no order |
| Storm | service_unavailable | 500 Hollywood Blvd, Los Angeles, CA 90028 | 503 service_unavailable, no order |
Roof reports (residential, commercial, Rapid Squares). Any address not in the table
runs cancelled. Times are seconds after the order is placed, and can run a second or two
late. Webhooks are held back about 5 seconds after each change, so the HTTP response to the call that
caused a change always arrives before the webhook announcing it.
Real reports. Each delivering address returns a different roof, recorded from a real property and run through the same parser and PDF path as a live order. The dashboard's Sandbox page has the PDF, XML and ESX for each, without ordering. The XML is the one to read if you're writing a parser.
ESX as live. A sandbox order includes the Xactimate ESX exactly when a live one would:
when it sends "esx": true, or leaves esx out on an account whose default is
on. Without it, report.esx is missing from files[] and the download returns
403 ESX_NOT_PURCHASED, so you can test both paths.
Rapid Squares delivers a recorded sample at three addresses (happy_path,
happy_path_4_structures and on_hold): the one-page PDF, the XML and the
photo the outlines were drawn on. It never comes with an ESX. At the
other delivering addresses a Rapid Squares order completes with no document.
Storm Reports in sandbox are free and return recorded reports that never change, in the response that orders them. Every PDF template, rendered from the Norman address, is under Storm report PDFs, and signed in, the dashboard's Sandbox page opens all five PDFs for every test address.
Any roof sandbox order that reaches completed can be revised; there is no special address
for it. Request a revision and you get a
revisions_requested webhook straight away, then revision_completed about 15
seconds later: the same two webhooks, in the same order, that a live revision sends. Storm Reports
aren't revised.
| Field | Required | Description |
|---|---|---|
reportType | No | Query parameter: residential, commercial, rapid_squares or storm lists only that type's addresses. Without it, every address. Anything else is 400 validation. |
curl -H "Authorization: Bearer rq_test_sk_..." \
"https://api.roofquery.com/api/v1/reports/sandbox/scenarios?reportType=residential"
Every address in the table, each with the reportTypes it's for, in this shape (one roof
and one storm entry shown). Roof entries carry their timeline; storm entries carry a
description and, where there is one, a suggestedDateOfLoss.
{
"note": "Sandbox keys (rq_test_sk_*) only, never charged. The address you order picks the scenario — exactly, or by coordinates within ~110m. Roof reports: any address not listed here runs the `cancelled` timeline. Storm Reports: any other address gets storm_busy with your address on it.",
"revisions": "Any roof sandbox order that reaches `completed` can be revised. The request emits `revisions_requested`, then the report is re-delivered and `revision_completed` fires about 15s later.",
"reportType": null,
"scenarios": [
{
"scenario": "happy_path",
"reportTypes": ["residential", "rapid_squares"],
"address": "6515 Turnbridge Pl, Prospect, KY 40059, USA",
"latitude": 38.3526391,
"longitude": -85.6208045,
"timeline": [
{ "status": "processing", "atSeconds": 0 },
{ "status": "in-progress", "atSeconds": 10 },
{ "status": "completed", "atSeconds": 20 }
],
"synchronousFailure": false
},
{
"scenario": "storm_busy",
"reportTypes": ["storm"],
"description": "Busy history: large hail, 75–130 mph wind, tornadoes, FEMA declarations",
"address": "3208 Riviera Dr, Norman, OK 73072, USA",
"latitude": 35.182884,
"longitude": -97.458908,
"suggestedDateOfLoss": "2025-05-25",
"damageChance": "high"
}
]
}
synchronousFailure: true means the order call fails before an order exists, as it would
live. With ?reportType=storm there is no revisions.
Who this key belongs to, and whether it can actually place an order. Call it once after
issuing a key to confirm it works, and later as a health probe. canOrder
is quoted against the cheapest product — it answers "can this account order at
all", not "can it afford a commercial report".
curl -H "Authorization: Bearer rq_live_sk_..." \
"https://api.roofquery.com/api/v1/identity"
{
"companyId": "rq_co_6a6dd83400073816",
"companyName": "Acme Roofing",
"sandbox": false,
"env": "live",
"keyId": "rqk_3d81aa07c2e95f14",
"balance": {
"available": 247,
"currency": "usd",
"canOrder": true
}
}
With a sandbox key, sandbox is true, env is test
and canOrder is always true, because sandbox orders never draw credit. See the
Quick start.
One balance funds every RoofQuery product, which is why this sits outside
/reports. Read live from Stripe, so credit added by hand in the Stripe
dashboard shows up immediately. Spend that hasn't finished settling is already
subtracted — available is what you can order against right now.
Also reachable at /api/v1/reports/balance, with an identical response,
for integrations that found it there first.
curl -H "Authorization: Bearer rq_live_sk_..." \
"https://api.roofquery.com/api/v1/balance"
{
"available": 247,
"currency": "usd",
"setupRequired": false,
"prices": {
"reports": { "residential": 10, "commercial": 30, "storm": 5 },
"addons": { "esx": 5 }
},
"remaining": { "residential": 24, "commercial": 8, "storm": 49 },
"residentialReportsRemaining": 24
}
remaining is how many of each product the balance covers right now, counted at
the report price without the ESX. prices.addons.esx is what the Xactimate ESX
adds to one roof report on this account.
residentialReportsRemaining is the same as remaining.residential, kept
for integrations written before remaining existed.
setupRequired: true means no card and no balance yet — see
BILLING_SETUP_REQUIRED under Errors.
Your subscribed endpoints. Account-level, like the balance and the keys themselves —
one set of endpoints receives events from every report, Storm Reports included (a storm report
sends one completed event, in the same envelope, when it's placed). Read-only: subscriptions are
created in the portal, not through the API, so a
leaked key can't quietly redirect your event stream. Sandbox orders deliver to the same
endpoints as live ones.
Also reachable at /api/v1/reports/webhooks, with an identical response.
curl -H "Authorization: Bearer rq_live_sk_..." \
"https://api.roofquery.com/api/v1/webhooks"
{
"webhooks": [
{
"webhookId": "rqwh_41c8e2f07a93d615",
"targetUrl": "https://acme.example.com/hooks/roofquery",
"eventType": "report",
"eventTypes": ["report"],
"active": true,
"createdAt": "2026-06-02T09:14:00.000Z",
"lastTriggeredAt": "2026-08-12T16:01:49.000Z",
"deactivatedReason": null
}
]
}
report is the one event stream: every status change of every roof report order. Switch on the
payload's status and event fields, described under
Order lifecycle.
A profile is one contractor you order for: a company name, a contact block, a license
number, two colours and a logo. Name it as brand when you
place an order, and that order's PDF
carries the contractor's brand, logo included, for good. The profile's
contact email and phone are never printed: they're who we reach if we need more
information about the property. Nothing about your own account defaults changes.
| Method | Path | Description |
|---|---|---|
| GET | /api/v1/branding/profiles | Every profile on the account, newest first |
| POST | /api/v1/branding/profiles | Create. Returns 201 with the profile and its id |
| GET | /api/v1/branding/profiles/:id | One profile |
| PATCH | /api/v1/branding/profiles/:id | Change any text or colour field. Omitted fields are left alone |
| DELETE | /api/v1/branding/profiles/:id | Delete the profile and its logo file. A profile that orders were placed for can't be deleted: 409 BRAND_IN_USE (edit it instead) |
| PUT | /api/v1/branding/profiles/:id/logo | Upload the logo: raw image bytes as the body, with an image Content-Type |
| DELETE | /api/v1/branding/profiles/:id/logo | Remove the logo. The rest of the profile stays |
The JSON body of POST and PATCH.
| Field | Required | Description |
|---|---|---|
companyName | To create | Prints on the header band, footer and cover attribution, and is how the profile is listed. 80 chars |
licenseNumber | No | Contact block, as License # …. 40 chars |
contactEmail | No | Never printed. Who we email about a property. 160 chars |
contactPhone | No | Never printed. Who we call about a property. 32 chars |
address | No | Contact block. 160 chars |
publicPhone | No | Contact block. 32 chars. A 10-digit number is formatted (502) 555-0100 |
publicEmail | No | Contact block. 160 chars |
brandColor | No | Hex. Defaults to RoofQuery blue |
brandTextColor | No | Hex. Picked for contrast when omitted |
With no contact on the profile, we reach your own account's contact instead; an order is never refused for it.
curl -X POST "https://api.roofquery.com/api/v1/branding/profiles" \
-H "Authorization: Bearer rq_live_sk_..." \
-H "Content-Type: application/json" \
-d '{
"companyName": "Acme Roofing",
"address": "900 Bardstown Rd, Louisville, KY 40204",
"publicPhone": "5025559876",
"publicEmail": "[email protected]",
"licenseNumber": "KY-RC-48213",
"contactEmail": "[email protected]",
"contactPhone": "5025550142",
"brandColor": "#0F7A4D"
}'
HTTP/1.1 201 Created
{
"profile": {
"profileId": "rq_brand_7f3a21c9d4e07b16",
"companyName": "Acme Roofing",
"address": "900 Bardstown Rd, Louisville, KY 40204",
"publicPhone": "5025559876",
"publicEmail": "[email protected]",
"licenseNumber": "KY-RC-48213",
"contactEmail": "[email protected]",
"contactPhone": "5025550142",
"brandColor": "#0F7A4D",
"brandTextColor": null,
"hasLogo": false,
"createdAt": "2026-09-09T14:02:11.000Z",
"updatedAt": "2026-09-09T14:02:11.000Z"
}
}
Then the logo, as bytes. No multipart, no JSON wrapper — the file is the body:
curl -X PUT "https://api.roofquery.com/api/v1/branding/profiles/rq_brand_7f3a21c9d4e07b16/logo" \
-H "Authorization: Bearer rq_live_sk_..." \
-H "Content-Type: image/png" \
--data-binary @acme-logo.png
And then order for that contractor with "brand": "rq_brand_7f3a21c9d4e07b16". Every PDF of that order carries Acme Roofing.
A profile is complete. Every printed field comes from the profile and nothing falls through to your account defaults — a profile with no phone number prints no phone number, rather than yours under the contractor's name. The PDF is drawn when it's downloaded, so editing a profile updates the reports ordered for it.
Orders placed before 22 October 2026 don't have a brand of their own: they
still take one on a download link as ?brand=<profileId>, checked
against the company that placed the order (an unknown or foreign id is
404 not_found, like an unknown order id). See
White labeling. Ids are
rq_brand_ plus 64 random bits.
Logos go through the same pipeline as your account logo: PNG, JPEG, WebP, GIF, AVIF or
SVG, under 5MB, squared to a 512×512 PNG with transparent padding. There is no URL
field, on purpose — see White
labeling. Up to 500 profiles per account; past that,
409 profile_limit. Profiles can also be created and edited by hand in the
portal under Branding → Profiles,
which shows each one's id and a test PDF.
validation (400), not_found (404), profile_limit and BRAND_IN_USE (409), and for
logos empty_upload (400), too_large (413, over 5MB),
payload_too_large (413, a body over 6MB), unsupported_type (415) and
unreadable_image (422). Every code is under Errors.
The report products you can order and what each costs you. Prices are per
account as well as per product, so an agreed rate shows up here rather than in a
separate document — listPrice comes back alongside so you can show what
the deal is worth. Read price off the product you're ordering rather than
assuming a single rate.
The Xactimate ESX is an option on residential and commercial reports, priced the same way:
esx.price is what it adds to one report on this account, and esx.default
says whether your orders include it when they don't send esx. List price is $5,
and your account's own price and default are what this returns. See
Place an order and
Add ESX to an order.
curl -H "Authorization: Bearer rq_live_sk_..." \
"https://api.roofquery.com/api/v1/reports/pricing"
{
"currency": "usd",
"products": [
{
"id": "residential",
"label": "Residential Roof Report",
"price": 10,
"listPrice": 10,
"custom": false,
"format": "xml",
"estimatedDeliveryHours": 2,
"esx": { "price": 5, "listPrice": 5, "custom": false, "default": false }
},
{
"id": "commercial",
"label": "Commercial Roof Report",
"price": 30,
"listPrice": 30,
"custom": false,
"format": "xml",
"estimatedDeliveryHours": 8,
"esx": { "price": 5, "listPrice": 5, "custom": false, "default": false }
},
{
"id": "rapid_squares",
"label": "Rapid Squares",
"price": 7,
"listPrice": 7,
"custom": false,
"format": "xml",
"estimatedDeliveryHours": 0.5
},
{
"id": "storm",
"label": "Storm Report",
"price": 5,
"listPrice": 5,
"custom": false,
"format": "json",
"estimatedDeliveryHours": 0
}
],
"sandbox": false,
"_note": null
}
With a sandbox key, sandbox is true and _note
explains that these are the prices a live order would cost.
For roof reports, id is the value you pass as reportType when ordering.
Storm reports are ordered the same way, with
"reportType": "storm",
and are free when nothing is found. Rapid Squares ($7) has no esx: it never
comes with an ESX.
Places an order and returns 201 with the order in its initial state.
Measurements arrive later — wait for the completed webhook rather than
polling.
| Field | Required | Description |
|---|---|---|
country | Yes | US or CA. Validated first — every other field depends on it |
street | Yes | string |
city | Yes | string |
state | Yes | 2-letter state or province abbreviation |
postalCode | Yes | US 12345 or 12345-6789; CA A1A 1A1. Format only — we don't check it agrees with the state |
latitude | Yes | number, −90 to 90 |
longitude | Yes | number, −180 to 180 |
scope | Yes | primary_only, primary_and_garage, or all_structures. Not defaulted |
reportType | No | residential (default), commercial, rapid_squares (Rapid Squares), or storm (Storm Reports, which take their own fields) |
esx | No | true to include the Xactimate .esx (+$5, or your account's ESX price in pricing), false to leave it out. Left out, your account's default applies (see esx.default in pricing; you can change it under Balance & billing). Residential and commercial only: true on rapid_squares is 400 ESX_NOT_SUPPORTED. You can also add it later |
brand | No | The contractor this order is for: a brand profile id. Its PDF carries that brand for good, and a download link can't change it. Left out, the report carries your account's own branding, also for good. An id that isn't one of your profiles is 400 BRAND_NOT_FOUND and nothing is ordered |
claim | No | Claim number for the job, up to 64 characters. It goes on the Xactimate .esx, and the file downloads as <claim>.esx — see the ESX endpoint. Set here or not at all |
notes | No | Free text for the technician |
Send an Idempotency-Key header (8–200 characters of letters, digits and
_ . : -) and a retry with the same key never places a second order. It returns the
first order with 200 instead of 201, even if the retry's body differs. While
the first attempt is still being placed, a retry gets 409 ORDER_IN_PROGRESS; if it
failed, 502 ORDER_FAILED with nothing charged, and a new key places a new order.
curl -X POST "https://api.roofquery.com/api/v1/reports/orders" \
-H "Idempotency-Key: henderson-6515-turnbridge" \
-H "Authorization: Bearer rq_live_sk_..." \
-H "Content-Type: application/json" \
-d '{
"country": "US",
"street": "6515 Turnbridge Pl",
"city": "Prospect",
"state": "KY",
"postalCode": "40059",
"latitude": 38.3526391,
"longitude": -85.6208045,
"scope": "primary_and_garage",
"reportType": "residential",
"esx": true,
"brand": "rq_brand_7f3a21c9d4e07b16",
"claim": "CLAIM12345",
"notes": "Detached garage at rear."
}'
201 Created. totals, structures and files are empty until the report is delivered. billing.amount is the whole charge, and billing.items breaks it down: the report, and the ESX when the order pays for one.
{
"orderId": "rq_ord_8f3a21c9d4e07b16",
"status": "processing",
"previousStatus": null,
"event": null,
"reason": null,
"sandbox": false,
"sandboxScenario": null,
"createdAt": "2026-08-12T14:03:11.000Z",
"updatedAt": "2026-08-12T14:03:11.000Z",
"completedAt": null,
"property": {
"address": "6515 Turnbridge Pl, Prospect, KY 40059, USA",
"latitude": 38.3526391,
"longitude": -85.6208045
},
"report": {
"type": "residential",
"brand": { "id": "rq_brand_7f3a21c9d4e07b16", "companyName": "Acme Roofing" },
"claim": "CLAIM12345",
"scope": "primary_and_garage",
"scopeLabel": "Primary structure and detached garage",
"notes": "Detached garage at rear.",
"format": "xml",
"esxIncluded": true
},
"billing": {
"amount": 15,
"currency": "usd",
"method": "account_balance",
"holdReference": "rq_ord_8f3a21c9d4e07b16",
"items": [
{ "item": "report", "amount": 10 },
{ "item": "esx", "amount": 5 }
],
"refunded": false,
"released": false
},
"revisions": [],
"totals": null,
"structures": null,
"files": []
}
Sandbox. A rq_test_sk_* key runs a scheduled timeline
against your real webhooks and nothing is measured. The scenario is chosen by
the address you order and nothing else — see
sandbox scenarios. There is no header
or body field that forces one, so a sandbox order and a live order are the same
request; only the key differs. esx works the same as live, including
your account's default, and nothing is charged.
validation and VALIDATION (400) for a missing or malformed field,
ESX_NOT_SUPPORTED (400) for "esx": true on Rapid Squares,
BRAND_NOT_FOUND (400) for a brand that isn't one of your profiles,
INSUFFICIENT_CREDITS and BILLING_SETUP_REQUIRED (402),
PRODUCT_NOT_ENABLED (403) for a beta product not turned on for the account, and
ORDER_FAILED (502). Every code is under Errors.
The whole order. Identical in shape to the webhook body and to
/status, so one handler
covers all three — that's the point of the shared payload builder. Once the report is
delivered, totals, structures[] and files[] fill
in. Field-by-field detail is under
Report data.
There is deliberately no paginated list endpoint. State is driven by webhooks, and a list endpoint just invites the polling the webhook stream already replaces.
curl -H "Authorization: Bearer rq_live_sk_..." \
"https://api.roofquery.com/api/v1/reports/orders/rq_ord_8f3a21c9d4e07b16"
Delivered order, trimmed — structures[].faces[] continues for every facet:
{
"orderId": "rq_ord_8f3a21c9d4e07b16",
"status": "completed",
"sandbox": false,
"createdAt": "2026-08-12T14:03:11.000Z",
"completedAt": "2026-08-12T16:01:44.000Z",
"property": {
"address": "6515 Turnbridge Pl, Prospect, KY 40059, USA",
"latitude": 38.3526391,
"longitude": -85.6208045
},
"report": {
"type": "residential",
"brand": null,
"scope": "primary_and_garage",
"scopeLabel": "Primary structure and detached garage",
"format": "xml",
"esxIncluded": true
},
"billing": {
"amount": 15, "currency": "usd", "method": "account_balance",
"holdReference": "rq_ord_8f3a21c9d4e07b16",
"items": [ { "item": "report", "amount": 10 }, { "item": "esx", "amount": 5 } ],
"refunded": false, "released": false
},
"revisions": [],
"totals": {
"structureCount": 1,
"totalAreaSqFt": 5597.29,
"totalSquares": 55.97,
"totalEdgeLengthFt": 1234.58,
"facetCount": 33,
"predominantPitch": "12/12",
"edgeTotals": {
"EAVE": { "lengthFt": 276.31, "count": 27 },
"RAKE": { "lengthFt": 169.20, "count": 13 },
"RIDGE": { "lengthFt": 114.58, "count": 12 },
"HIP": { "lengthFt": 304.25, "count": 20 },
"VALLEY": { "lengthFt": 237.30, "count": 17 },
"STEP_FLASHING": { "lengthFt": 72.81, "count": 9 },
"WALL_FLASHING": { "lengthFt": 55.45, "count": 14 },
"PARAPET": { "lengthFt": 0.00, "count": 0 },
"BEND": { "lengthFt": 4.68, "count": 1 },
"OTHER": { "lengthFt": 0.00, "count": 0 }
},
"hipsAndRidgesLengthFt": 418.83,
"pitchAreas": { "12/12": 5204.08, "5/12": 257.97, "11/12": 66.42, "10/12": 48.56, "3/12": 20.26 },
"complexity": "complex",
"estimatedAtticSqFt": 4023.89,
"diagrams": {
"outline": "https://api.roofquery.com/api/v1/reports/orders/rq_ord_8f3a21c9d4e07b16/diagrams/all/outline",
"area": "https://api.roofquery.com/api/v1/reports/orders/rq_ord_8f3a21c9d4e07b16/diagrams/all/area",
"pitch": "https://api.roofquery.com/api/v1/reports/orders/rq_ord_8f3a21c9d4e07b16/diagrams/all/pitch",
"lengths": "https://api.roofquery.com/api/v1/reports/orders/rq_ord_8f3a21c9d4e07b16/diagrams/all/lengths"
}
},
"structures": [
{
"index": 1,
"facetCount": 33,
"totalAreaSqFt": 5597.29,
"totalSquares": 55.97,
"predominantPitch": "12/12",
"edgeTotals": { "...": "same ten categories, for this building" },
"hipsAndRidgesLengthFt": 418.83,
"complexity": "complex",
"estimatedAtticSqFt": 4023.89,
"diagrams": { "...": "same four types, addressed to structure 1" },
"faces": [
{
"id": "F1",
"areaSqFt": 338.51,
"pitch": "12/12",
"edges": [
{ "type": "RAKE", "lengthFt": 23.49 },
{ "type": "EAVE", "lengthFt": 6.11 },
{ "type": "VALLEY", "lengthFt": 28.76 },
{ "type": "RIDGE", "lengthFt": 22.72 }
]
}
]
}
],
"files": [
{ "name": "report.pdf", "url": "https://api.roofquery.com/api/v1/reports/orders/rq_ord_8f3a21c9d4e07b16/pdf" },
{ "name": "report.xml", "url": "https://api.roofquery.com/api/v1/reports/orders/rq_ord_8f3a21c9d4e07b16/xml" },
{ "name": "report.esx", "url": "https://api.roofquery.com/api/v1/reports/orders/rq_ord_8f3a21c9d4e07b16/esx" }
]
}
report.esx is in files[] only when report.esxIncluded is
true: the order was placed with ESX, has had it
added since, or was placed before ESX
became an option. billing.items lists the report and, when paid for, the ESX.
report.brand is the contractor the order was placed for
({ "id", "companyName" }), or null when it carries your account's own branding.
An unknown order id and an order belonging to someone else both return
404 not_found, never 403 — otherwise the API could be used to
probe which order ids exist.
Everything GET /orders/:id
returns, plus a statusDetail block that answers the two questions you'd
otherwise write a switch statement for: is anything further expected, and can I
download yet.
curl -H "Authorization: Bearer rq_live_sk_..." \
"https://api.roofquery.com/api/v1/reports/orders/rq_ord_8f3a21c9d4e07b16/status"
{
"orderId": "rq_ord_8f3a21c9d4e07b16",
"status": "in-progress",
"statusDetail": {
"code": "in-progress",
"label": "In progress",
"description": "A technician is measuring the property.",
"terminal": false,
"filesReady": false,
"known": true,
"changedAt": "2026-08-12T14:22:07.000Z",
"estimatedDeliveryHours": 2,
"estimatedReadyAt": "2026-08-12T16:03:11.000Z",
"openRevisionId": null
},
"totals": null,
"files": []
}
| Field | Description |
|---|---|
terminal | Nothing further is expected on this order |
filesReady | The PDF and XML (and the ESX, when the order includes it) can be fetched right now. Also requires files[] to be non-empty, so it can't disagree with the payload |
known | false if we add a status your code hasn't seen. Branch on this rather than on an exhaustive switch |
changedAt | When the order last moved |
estimatedReadyAt | Order time plus the product's typical turnaround. null once files are ready |
openRevisionId | The revision currently open, or null |
status stays the same plain string it is in the webhook payload, so a
handler written against webhooks works here unchanged. Turnaround — around 2 hours
residential, 8 commercial — is an estimate, not a commitment. Errors are the same as
GET /orders/:id.
Adds the Xactimate .esx to an order you've already placed, for your account's ESX
price ($5 list). It works before the report is delivered, in which case the ESX is ready when the
report is, and after.
The charge comes from your balance like the report's, and the order keeps it: if the order is later cancelled, the report and the ESX are refunded together. An order can only have ESX added once, so calling this twice, or from two places at the same time, charges once. Every order placed before ESX became an option already has it.
None, and no request body. There's no need for an Idempotency-Key: a repeat call is
already safe.
curl -X POST "https://api.roofquery.com/api/v1/reports/orders/rq_ord_8f3a21c9d4e07b16/esx" \
-H "Authorization: Bearer rq_live_sk_..."
201 Created when the ESX was added. order is the whole order, the same
as GET /reports/orders/:id
returns, so you can replace what you stored.
{
"upgrade": { "item": "esx", "amount": 5, "currency": "usd", "charged": true },
"order": {
"orderId": "rq_ord_8f3a21c9d4e07b16",
"status": "completed",
"report": { "type": "residential", "esxIncluded": true, ... },
"billing": {
"amount": 15,
"currency": "usd",
"method": "account_balance",
"holdReference": "rq_ord_8f3a21c9d4e07b16",
"items": [
{ "item": "report", "amount": 10 },
{ "item": "esx", "amount": 5 }
],
"refunded": false,
"released": false
},
"files": [
{ "name": "report.pdf", "url": "https://api.roofquery.com/api/v1/reports/orders/rq_ord_8f3a21c9d4e07b16/pdf" },
{ "name": "report.xml", "url": "https://api.roofquery.com/api/v1/reports/orders/rq_ord_8f3a21c9d4e07b16/xml" },
{ "name": "report.esx", "url": "https://api.roofquery.com/api/v1/reports/orders/rq_ord_8f3a21c9d4e07b16/esx" }
],
...
}
}
200 OK when the order already had its ESX: the same body with
"amount": 0 and "charged": false. Nothing was taken.
With a sandbox key the ESX is added for free: "amount": 0, "charged": false.
files is empty until the report is delivered.
| Code | HTTP | Meaning |
|---|---|---|
INSUFFICIENT_CREDITS | 402 | Available balance won't cover the ESX. Nothing was charged |
BILLING_SETUP_REQUIRED | 402 | No card or balance on the account yet |
not_found | 404 | No such order on this account |
NOT_UPGRADABLE | 409 | The order was cancelled |
ESX_NOT_SUPPORTED | 409 | A Rapid Squares order: it never has an ESX |
Every code is under Errors.
The white-labeled report, rendered at the moment you request it, in the brand the order was
placed with: the contractor named as brand when ordering, or your account's own
branding. Nothing is cached, so editing that brand updates the report on its next download.
No API key required — see
Sharing a report.
A storm report id takes its own parameters: see
Storm report PDFs.
All optional, all query parameters. Only download applies to every order:
the branding parameters apply only to orders placed before 22 October 2026, which have no
brand of their own. On later orders they're ignored.
| Field | Required | Description |
|---|---|---|
download | No | 1 forces a save dialog. Without it the PDF is served inline, so you can iframe it |
brand | No | Orders placed before 22 October 2026: a brand profile id. The report renders in that customer's brand, logo included |
companyName | No | Name on the header band, footer and cover attribution. Up to 80 characters |
address | No | First line of the contact block, under the name. Up to 160 characters |
publicPhone | No | Contact block. A 10-digit number is formatted (502) 555-0100; anything else prints as sent. Up to 32 characters |
publicEmail | No | Contact block. Up to 160 characters |
brandColor | No | Hex, #RGB through #RRGGBBAA. URL-encode the # as %23 |
brandTextColor | No | Hex. Text on the header band |
Which brand wins, on an order from before 22 October 2026. Most specific first:
these params, then a brand profile, then your account defaults. Later orders
always print the brand they were ordered with. See
White labeling.
Override the contact block as a unit. The header renders the company
name with the address, phone and email stacked underneath it, so passing
companyName alone puts a different company's name above your
phone number — a report that tells the reader to call someone other than who it claims
to be from. If you're rebranding a download, send all four.
There is no logo param: a logo is bytes, not something that fits in a URL. To put a
contractor's logo on their reports, upload it once to a
brand profile and name the
profile as brand when you order.
curl "https://api.roofquery.com/api/v1/reports/orders/rq_ord_8f3a21c9d4e07b16/pdf?download=1" \
-o report.pdf
The PDF bytes:
HTTP/1.1 200 OK
Content-Type: application/pdf
Content-Disposition: attachment; filename="RoofQuery_6515_Turnbridge_Pl_Prospect_KY_40059_USA_rq_ord_8f3a21c9d4e07b16.pdf"
Content-Length: 1370151
Cache-Control: no-store
Before the report is delivered:
HTTP/1.1 409 Conflict
{
"error": "not_ready",
"error_description": "Report has not completed yet — no PDF available.",
"status": "in-progress"
}
A colour that isn't a hex string returns 400 validation rather than rendering
something strange, and text over its length is refused the same way rather than silently cut,
because these render in a fixed-height header (checked on every order, even where the
params are then ignored). An unknown order, or an unknown brand on an older order,
returns 404 not_found. Every code is under Errors.
The measurement document, carrying no branding at all. Every number in
totals is derived from this file, so it's the thing to reach for if you
want to compute something we don't publish. Root element
<ROOFQUERY_EXPORT>, then straight into the geometry — exactly as
measured.
curl "https://api.roofquery.com/api/v1/reports/orders/rq_ord_8f3a21c9d4e07b16/xml" \
-o report.xml
HTTP/1.1 200 OK
Content-Type: application/xml; charset=utf-8
Content-Disposition: attachment; filename="RoofQuery_6515_Turnbridge_Pl_Prospect_KY_40059_USA_rq_ord_8f3a21c9d4e07b16.xml"
<?xml version="1.0" encoding="UTF-8"?>
<ROOFQUERY_EXPORT>
<STRUCTURES>
<ROOF id="ROOF1">
<FACES>
<FACE id="F1" slope="12.000000">
<POLYGON id="P1" area="31447836.5" path="L1,L4,L9,L12"/>
</FACE>
...
</FACES>
<LINES>
<LINE id="L1" path="C1,C2" subtype="EAVE" type="EDGE"/>
...
</LINES>
<POINTS>
<POINT id="C1" data="131.641073,98.633976,16.085879"/>
...
</POINTS>
</ROOF>
</STRUCTURES>
</ROOFQUERY_EXPORT>
A Rapid Squares order's XML has the same root, marked
product="rapid_squares", with outlines instead of faces.
A document declares one <ROOF> per parcel, not per building — the
structure count in totals is inferred from geometry, and
structures[] is ordered largest first.
409 not_ready until the order completes, the same shape as the
PDF route, and 404 not_found for an unknown
order. Every code is under Errors.
The Xactimate-compatible .esx for the order — the
sketch, prepared by the annotation team alongside your report, ready to import.
Always the current version of the report: after a revision, this gives you the
corrected one.
On orders that include it. The ESX is an option on residential and
commercial reports: "esx": true when you
place the order (+$5, or your
account's ESX price), or add it later,
before or after delivery. Every order placed before ESX became an option has it. Downloading
it draws nothing; an order with ESX can be downloaded as often as you like.
report.esxIncluded on the order says whether it has one.
None. Give the order a claim on
POST /reports/orders
and it goes on the .esx. The file downloads as
<claim>.esx, so it lands under the number your estimator already
knows the job by. The claim is set at ordering or not at all, and without one the file is
named from the street line of the ordered address.
curl "https://api.roofquery.com/api/v1/reports/orders/rq_ord_8f3a21c9d4e07b16/esx" \
-o report.esx
HTTP/1.1 200 OK
Content-Type: application/octet-stream
Content-Disposition: attachment; filename="CLAIM12345.esx"
Cache-Control: no-store
Filenames are 16 characters, A-Za-z0-9_.- only. A longer
claim is truncated and anything outside that set becomes an underscore — the claim on
the order is kept exactly as you sent it; only the filename bends. Read
Content-Disposition if you need to know what it became.
Open it in Xactimate as you would any other ESX; there is nothing RoofQuery-specific to
configure. It is not a format to read yourself — if you want the measurements as data,
take the /xml instead.
| Claim on the order | Filename | Why |
|---|---|---|
CLAIM12345 | CLAIM12345.esx | Already legal — used untouched |
123 Main St/Apt#4 | 123_Main_St_Apt.esx | Space, slash and hash substituted; runs collapsed; trailing separator trimmed |
12/34 Main Street Apt #7 | 12_34_Main_Stree.esx | Same, then cut at 16 |
| none, on 6515 Turnbridge Pl | 6515_Turnbridge.esx | The street line of the ordered address |
The street line rather than the whole address, because 16 characters of "6515 Turnbridge Pl, Prospect, KY 40059, USA" spends most of its budget on the part that is the same for every property on the street.
403 ESX_NOT_PURCHASED when the order doesn't include ESX: the message names
POST /api/v1/reports/orders/:id/esx
and what it costs on your account. 409 not_ready until the order completes. We
check the file before sending it, so a bad one comes back as 502 download_failed
rather than as a .esx that fails to open; it is safe to retry. A
Rapid Squares order has no ESX and returns 404 ESX_NOT_SUPPORTED. Every code is under Errors.
The catalog: every drawable target for this order, already addressed, so a client can
build a picker without hardcoding our vocabulary. The same links appear inside
totals.diagrams and each structures[].diagrams.
curl "https://api.roofquery.com/api/v1/reports/orders/rq_ord_8f3a21c9d4e07b16/diagrams"
{
"types": [
{ "id": "outline", "label": "Outline", "description": "The roof plan — facets and edges, unlabelled." },
{ "id": "area", "label": "Area", "description": "Each facet labelled with its area in square feet." },
{ "id": "pitch", "label": "Pitch", "description": "Each facet labelled with its pitch, in twelfths." },
{ "id": "lengths", "label": "Lengths", "description": "Every edge colour-coded by type and labelled with its true sloped length in feet." }
],
"formats": ["svg", "png"],
"size": { "default": 1000, "min": 200, "max": 4000 },
"available": [
{
"structure": "all",
"label": "Whole property",
"diagrams": {
"outline": "https://api.roofquery.com/api/v1/reports/orders/rq_ord_8f3a21c9d4e07b16/diagrams/all/outline",
"area": "https://api.roofquery.com/api/v1/reports/orders/rq_ord_8f3a21c9d4e07b16/diagrams/all/area",
"pitch": "https://api.roofquery.com/api/v1/reports/orders/rq_ord_8f3a21c9d4e07b16/diagrams/all/pitch",
"lengths": "https://api.roofquery.com/api/v1/reports/orders/rq_ord_8f3a21c9d4e07b16/diagrams/all/lengths"
}
},
{
"structure": 1,
"label": "Structure 1",
"totalAreaSqFt": 5597.29,
"diagrams": { "...": "same four types, addressed to structure 1" }
}
]
}
Only 404 not_found, for an unknown order id. Before the order is delivered, and on a
Rapid Squares order (no roof faces to draw), it answers
200 with available empty. Every code is under
Errors.
One roof drawing — the same sketches the PDF prints, as a standalone image. Rendered on the fly from the delivered document, so a report re-delivered after a revision produces new drawings from the same URLs with no cache to invalidate.
| Field | Required | Description |
|---|---|---|
:structure | Yes, in the path | all, or a 1-based structures[].index — 1 is the largest building. The index matches the figures beside it, so a link and its numbers always describe the same roof |
:type | Yes, in the path | outline, area, pitch, lengths |
format | No | svg (default) or png |
size | No | Edge length in pixels. Default 1000, min 200, max 4000. Always square |
download | No | 1 forces a save dialog instead of inline |
curl "https://api.roofquery.com/api/v1/reports/orders/rq_ord_8f3a21c9d4e07b16/diagrams/1/area?format=png&size=2000" \
-o structure-1-area.png
HTTP/1.1 200 OK
Content-Type: image/png
Content-Disposition: inline; filename="rq_ord_8f3a21c9d4e07b16-structure-1-area.png"
Cache-Control: no-store
SVG is the default because it's a line drawing: it scales to any size and it's a fraction of the bytes. Square is the default because a roof plan has no natural aspect ratio, and a square drops into a grid or a card without you reasoning about letterboxing.
An unknown :type, format or size, or a :structure
that isn't all or a whole number, returns 400 validation; a structure number
the order doesn't have returns 404 not_found; a
Rapid Squares order returns 404 not_available; an
undelivered order returns 409 not_ready; a structure with nothing drawable returns
409 no_geometry. Every code is under
Errors.
Ask for a correction to a delivered report. Free — a revision corrects work you already paid for and draws no further balance. The rules are under Revisions.
| Field | Required | Description |
|---|---|---|
topic | Yes | One of the values from /reports/revision-options |
notes | Yes | Trimmed and truncated at 2000 characters — anything longer is silently cut, not rejected. Read by the person making the correction, so name the structure and the facet |
curl -X POST "https://api.roofquery.com/api/v1/reports/orders/rq_ord_8f3a21c9d4e07b16/revisions" \
-H "Authorization: Bearer rq_live_sk_..." \
-H "Content-Type: application/json" \
-d '{"topic":"Roof Has Wrong Pitch","notes":"Front elevation reads 12/12, closer to 6/12."}'
201 Created. The updated order comes back alongside, so you don't need a second call:
{
"revision": {
"revisionId": "rq_rev_2b91f4c07e13",
"topic": "Roof Has Wrong Pitch",
"notes": "Front elevation reads 12/12, closer to 6/12.",
"status": "open",
"requestedAt": "2026-08-12T17:20:03.000Z",
"completedAt": null
},
"order": {
"orderId": "rq_ord_8f3a21c9d4e07b16",
"status": "revisions_requested",
"files": [ "...still the previously delivered report" ],
"revisions": [ { "revisionId": "rq_rev_2b91f4c07e13", "status": "open", "...": "" } ]
}
}
VALIDATION and INVALID_TOPIC (400), NOT_REVISABLE (409; 400 on a
storm report, which is never revised), REVISION_ALREADY_OPEN and
CLAIM_NOT_FOUND (409), REVISION_REJECTED (409, or the 4xx or 502 the refusal
came with), REVISION_FAILED (502, safe to retry) and NO_CLAIM (500, contact
support). Every code is under
Errors.
Every revision ever raised on one order, open and historical. The same array appears on
the order payload as revisions[] — this endpoint is for when that's all
you want.
curl -H "Authorization: Bearer rq_live_sk_..." \
"https://api.roofquery.com/api/v1/reports/orders/rq_ord_8f3a21c9d4e07b16/revisions"
{
"revisions": [
{
"revisionId": "rq_rev_2b91f4c07e13",
"topic": "Roof Has Wrong Pitch",
"notes": "Front elevation reads 12/12, closer to 6/12.",
"status": "completed",
"requestedAt": "2026-08-12T17:20:03.000Z",
"completedAt": "2026-08-12T18:44:51.000Z"
}
]
}
The topics currently accepted. Fetch it rather than hardcoding the list — it's the same in sandbox and live, but it's ours to change.
curl -H "Authorization: Bearer rq_live_sk_..." \
"https://api.roofquery.com/api/v1/reports/revision-options"
{
"source": "upstream",
"topics": [
{ "value": "Roof Has Wrong Pitch", "label": "Roof Has Wrong Pitch" },
{ "value": "Missing Structures", "label": "Missing Structures" },
{ "value": "Wrong Structure", "label": "Wrong Structure" },
{ "value": "Measurements Are Off", "label": "Measurements Are Off" },
{ "value": "Other", "label": "Other" }
],
"rules": {
"orderStatusRequired": "completed",
"revisableStatuses": ["completed", "updated"],
"oneOpenRevisionPerOrder": true,
"cost": 0
}
}
Pass topics[].value as topic; label is for your
own picker. source is upstream, sandbox, or
fallback if the live list was briefly unavailable — the topic list is the
same either way, so it's diagnostic rather than something to branch on.
| Topic | Use it when |
|---|---|
Roof Has Wrong Pitch | One or more pitches are wrong. |
Missing Structures | A building on the property isn't in the report. |
Wrong Structure | The report includes something it shouldn't. |
Measurements Are Off | Areas or lengths don't match the property. |
Other | Anything else — explain it in notes. |
The roof in squares, without the full report: each building's outline traced on an aerial photo and one estimated pitch per building, delivered as a one-page PDF, an XML and the photo it was traced on. For quoting and ordering material when you don't need facets, edges or diagrams. Ordered, tracked and delivered like any roof report: the same endpoints, statuses, webhooks and sandbox.
| What | How |
|---|---|
| Order | POST /api/v1/reports/orders with "reportType": "rapid_squares". Every other field as a roof report. |
| Price | $7 from your balance (or your account's price in pricing). Ordering needs $7 available. |
| Delivery | Under 30 minutes, then a completed webhook as for any report. |
| Files | A one-page PDF (the property photo with each building outlined in its colour, and the squares at our estimated pitch, and a table with the squares for each pitch from 1/12 to 12/12, with 10, 12 and 15% waste, in case the real pitch is different), the XML, and the photo the outlines were traced on, to draw them over yourself. No ESX and no diagrams: there are no roof faces to draw (those routes answer 404, and an order sent with "esx": true is refused with 400 ESX_NOT_SUPPORTED, as is adding one later, with 409). |
| Pitch | One estimated pitch per building. The table gives every other pitch too, so you can use your own. |
curl -X POST "https://api.roofquery.com/api/v1/reports/orders" \
-H "Authorization: Bearer rq_live_sk_..." \
-H "Content-Type: application/json" \
-d '{
"country": "US",
"street": "6515 Turnbridge Pl",
"city": "Prospect",
"state": "KY",
"postalCode": "40059",
"latitude": 38.3526391,
"longitude": -85.6208045,
"scope": "all_structures",
"reportType": "rapid_squares"
}'
A delivered order's totals and structures[] carry squares instead of
facets and edges. totalSquares is at the estimated pitch;
squaresByPitch is each building's squares for each pitch from 1/12 to 12/12, so you can use a different pitch than ours.
"totals": {
"structureCount": 1,
"totalAreaSqFt": 5460.7,
"totalSquares": 54.61,
"footprintSqFt": 3861.3,
"perimeterFt": 288.03,
"wastePct": 10,
"totalSquaresWithWaste": 60.07
},
"structures": [
{
"index": 1,
"name": "Structure 1",
"estimatedPitch": "12/12",
"totalAreaSqFt": 5460.7,
"totalSquares": 54.61,
"footprintSqFt": 3861.3,
"perimeterFt": 288.03,
"squaresByPitch": { "1/12": 38.75, "4/12": 40.7, "6/12": 43.17, "8/12": 46.41, "12/12": 54.61, ... }
}
],
"files": [
{ "name": "report.pdf", "url": "https://api.roofquery.com/api/v1/reports/orders/rq_ord_8f3a21c9d4e07b16/pdf" },
{ "name": "report.xml", "url": "https://api.roofquery.com/api/v1/reports/orders/rq_ord_8f3a21c9d4e07b16/xml" },
{ "name": "photo.jpg", "url": "https://api.roofquery.com/api/v1/reports/orders/rq_ord_8f3a21c9d4e07b16/photo" }
]
The XML is RoofQuery's own export, marked product="rapid_squares": the totals, then
each building's pitch, its outlines and any holes (a courtyard). Points are in feet (x east,
y north), and px, py are the same corner on the photo it was traced on,
in pixels from its top left.
<ROOFQUERY_EXPORT version="1" product="rapid_squares" units="feet" pitchunits="in/12">
<LOCATION address="6515 Turnbridge Pl, Prospect, KY 40059, USA" lat="38.3526391" long="-85.6208045"/>
<IMAGE source="azure" zoom="19" width="640" height="640" ftperpx="0.3841" rotation="0"/>
<TOTALS plansqft="3861.3" areasqft="5460.7" squares="54.61" wastepct="10" squareswithwaste="60.07" perimeterft="288.03"/>
<STRUCTURES>
<STRUCTURE id="1" name="Structure 1" pitch="12" plansqft="3861.3" areasqft="5460.7" squares="54.61" perimeterft="288.03">
<OUTLINE>
<POINT x="-29.2614" y="-41.6303" px="243.82" py="428.38"/>
...
</OUTLINE>
</STRUCTURE>
</STRUCTURES>
</ROOFQUERY_EXPORT>
The photo the outlines were traced on, a JPEG exactly as traced (no outlines, no branding). Each
<POINT>'s px, py in the XML is that corner's pixel on
it, from its top left, so the outlines go over it with no scaling or turning:
<IMAGE width height> are its size and ftperpx its scale. No key, like the
PDF and XML. 409 not_ready until the order completes; 404 not_available on
any other kind of report.
curl "https://api.roofquery.com/api/v1/reports/orders/rq_ord_8f3a21c9d4e07b16/photo" -o photo.jpg
// the outlines over the photo, in a browser
const xml = new DOMParser().parseFromString(await (await fetch(xmlUrl)).text(), 'application/xml');
const img = await new Promise((ok) => { const i = new Image(); i.onload = () => ok(i); i.src = photoUrl; });
const c = document.createElement('canvas'), g = c.getContext('2d');
c.width = img.naturalWidth; c.height = img.naturalHeight; g.drawImage(img, 0, 0);
for (const shape of xml.querySelectorAll('OUTLINE, HOLE')) {
g.beginPath();
shape.querySelectorAll('POINT').forEach((p, i) => g[i ? 'lineTo' : 'moveTo'](+p.getAttribute('px'), +p.getAttribute('py')));
g.closePath(); g.lineWidth = 3; g.strokeStyle = '#1668b8'; g.stroke();
}
Sandbox: a Rapid Squares order on a sandbox key delivers a recorded report (PDF, XML and photo) on the usual sandbox timeline, with its webhooks, free.
Hail, wind and tornado history for an address in the US or Canada, returned in the same response that orders it. $5 per report, and free when nothing is found: if no storm of any kind was recorded within 5 miles in the last five years, the report is still delivered and nothing is charged.
schemaVersion on every report: it goes up whenever the structure changes, and each
change is announced in the changelog first. A report you already ordered
keeps the data it was ordered with.
| What | Detail |
|---|---|
| Price | $5 from your balance. $0 when nothing was recorded within 5 miles (billing.reason: "no_storm_data"). |
| Speed | About 2 seconds. The response is the report, and a completed webhook goes out too, like every report. |
| Period | The last five years. Radar hail goes back to October 2020. |
| Coverage | US and Canada. Canada has radar hail and weather stations, but no public archive of storm reports or warnings, and the report says so. |
| Data | Stored when you order and never changed. Reading the report or downloading a PDF a year later shows exactly the same data. |
| PDFs | Five with every order: Hail Verification, Wind Verification, Date of Loss, Weather History and Homeowner Storm Summary, branded like your roof reports. |
What can change on every read: the date of loss, claim number, insured name, notes and the branding of the PDF. They are defaults when you order, and any read or download can pass its own, so one order can be re-issued for a different date of loss without paying again.
Order a storm report · Storm report data · Storm report PDFs · Sandbox
Order a storm report: the same endpoint as roof reports, with "reportType": "storm".
Returns 201 with the whole report in the body, about two
seconds after you send it. Retrying with the same Idempotency-Key returns the same
report with 200 and never charges twice.
| Field | Required | Description |
|---|---|---|
reportType | Yes | "storm". Without it the order is a roof report. |
street, city, state, postalCode, country | Yes | Strings. The same address rules as roof orders; country is US or CA. |
latitude, longitude | Yes | Numbers, in decimal degrees. |
dateOfLoss | No | YYYY-MM-DD. Highlights that date and enables the Date of Loss PDF. Can't be in the future. |
claim | No | Up to 64 characters. Printed on the report. |
insuredName | No | Up to 120 characters. Printed on the report. |
notes | No | Up to 450 characters. Printed on the five writing lines of the Notes box on every PDF; line breaks are kept. The lines left over stay blank to write on. |
reportName | No | Up to 300 characters. Your own label. |
curl -X POST "https://api.roofquery.com/api/v1/reports/orders" \
-H "Authorization: Bearer rq_live_sk_..." \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 6f1c2e0a-storm-001" \
-d '{
"reportType": "storm",
"street": "3208 Riviera Dr", "city": "Norman", "state": "OK",
"postalCode": "73072", "country": "US",
"latitude": 35.182884, "longitude": -97.458908,
"dateOfLoss": "2025-05-25",
"claim": "CLM-20419",
"insuredName": "Jordan Smith",
"notes": "Inspection booked for Monday."
}'
201 Created with the whole report: every field is under Storm report data.
There is no scope, esx or brand: a storm report covers the property, and its PDFs take the brand when they are downloaded.
Ordering needs $5 available even when the result turns out free; otherwise the answer is
402 INSUFFICIENT_CREDITS and nothing is looked up. If weather data doesn't answer in
time the answer is 503 service_unavailable, with no order and no charge. Every code is
under Errors.
The same endpoint as roof reports: for a storm report id it answers with the report as stored. Pass any of dateOfLoss, claimNumber,
insuredName, notes and reportName to read it with different details; an empty value
clears one. The weather data is the same on every read.
curl -H "Authorization: Bearer rq_live_sk_..." \
"https://api.roofquery.com/api/v1/reports/orders/rq_ord_7f3c2a91d04be615?dateOfLoss=2024-11-02"
The same JSON as the order response, with this read's details. Every field is under Storm report data.
Storm reports have no revisions: change the details on read, or order again for fresh data.
A storm report comes in the same envelope as every report (orderId, status,
property, report, billing, files, as in
Get an order), with the weather data under
storm. Abridged here to one row per list. Units are in the field
names (In inches, Mph, Mi), dates are storm days
(YYYY-MM-DD, 12:00–12:00 UTC), and empty is null or [], never a
missing key (the one exception: storm.documents[].reason is there only on a document that
needs a date of loss).
storm.schemaVersion; changes are announced in the changelog first.
{
"orderId": "rq_ord_7f3c2a91d04be615",
"status": "completed",
"previousStatus": null, "event": null, "reason": null,
"sandbox": false, "sandboxScenario": null,
"createdAt": "2026-10-17T14:02:11Z", "updatedAt": "2026-10-17T14:02:11Z", "completedAt": "2026-10-17T14:02:11Z",
"property": {
"address": "3208 Riviera Dr, Norman, OK 73072, USA",
"latitude": 35.182884, "longitude": -97.458908, "country": "US", "county": "Cleveland County, OK",
"parcel": {
"parcelId": "NC29CANU2 2 37001",
"owner": { "raw": "SCHUTJER, CLIFFORD & PATRICIA M", "type": "person", "names": ["Clifford Schutjer", "Patricia M Schutjer"] },
"areaSqft": 6887,
"bounds": { "west": -97.45914, "south": 35.18281, "east": -97.45867, "north": 35.18296 },
"boundary": { "type": "Polygon", "coordinates": [[[-97.45914, 35.18281], "…"]] }
}
},
"report": { "type": "storm", "claim": "CLM-20419", "brand": null, "insuredName": "Jordan Smith", "dateOfLoss": "2025-05-25", "reportName": null, "notes": "Inspection booked for Monday.", "format": "json" },
"billing": { "amount": 5, "currency": "usd", "method": "account_balance", "items": [{ "item": "report", "amount": 5 }], "charged": true, "reason": null, "refunded": false, "released": false },
"files": [
{ "name": "hail_verification.pdf", "title": "Hail Verification", "url": "https://api.roofquery.com/api/v1/reports/orders/rq_ord_7f3c2a91d04be615/pdf?template=hail_verification" },
"… wind_verification.pdf, date_of_loss.pdf, weather_history.pdf, homeowner.pdf",
{ "name": "map.png", "url": "https://api.roofquery.com/api/v1/reports/orders/rq_ord_7f3c2a91d04be615/map.png" }
],
"storm": {
"schemaVersion": 1,
"dataAsOf": "2026-10-17T13:58:00Z",
"period": { "from": "2021-10-17", "to": "2026-10-17", "years": 5, "radarHailFrom": "2020-10-14" },
"headline": "Hail was estimated at or within 1 mile of this property on 8 days since 2021. …",
"damageChance": { "level": "high", "reason": "Large hail or damaging wind reached the property or came very close.", "drivenBy": ["2024-11-02"] },
"summary": {
"stormDays": 59, "hailDays": 18, "windDays": 30, "tornadoDays": 8, "hailDaysNearProperty": 8,
"largestHail": { "sizeIn": 2, "sizeName": "hen egg", "date": "2023-06-11", "where": "within_3_mi", "confidence": "high" },
"strongestWind": { "mph": 130, "distanceMi": 3.2, "source": "nws_damage_survey", "date": "2023-02-26", "confidence": "high" },
"closestTornado": { "distanceMi": 1.1, "date": "2023-02-26", "efScale": "EF2", "confidence": "medium" }
},
"dateOfLoss": {
"date": "2025-05-25", "status": "found", "headline": "On the storm day of May 25, 2025, …",
"hazards": ["hail", "flood"], "confidence": "high", "damageChance": "medium",
"byDistance": [ { "area": "at_property", "radarHailIn": 1, "largestHailReport": { "sizeIn": 1.25, "distanceMi": 0.2 }, "strongestGust": null, "tornadoMi": null, "warnings": ["Severe Thunderstorm Warning"] } ],
"nearestStormBefore": { "date": "2025-05-23", "daysAway": -2 }, "nearestStormAfter": { "date": "2025-06-03", "daysAway": 9 }
},
"hail": { "rule": "…", "events": [ { "date": "2025-05-25", "isDateOfLoss": true, "atPropertyIn": 1, "within1MiIn": 1.25, "within3MiIn": 1.75, "within5MiIn": 2.5, "largestReport": { "sizeIn": 3, "distanceMi": 3.7 }, "confidence": "high", "damageChance": "medium" } ] },
"wind": { "rule": "…", "events": [ { "date": "2024-11-02", "within1MiMph": 75, "within3MiMph": 75, "within5MiMph": 93, "damageReports": { "count": 2, "closestMi": 1.8 }, "station": { "name": "Norman/Westheimer", "gustMph": 26, "distanceMi": 4.2 }, "tornado": { "distanceMi": 2.9, "efScale": "EF0" }, "confidence": "high", "damageChance": "high" } ] },
"stormDays": [ { "date": "2025-05-25", "hazards": ["hail", "flood"], "confidence": "high", "maxHailIn": 3, "maxGustMph": 30, "evidence": "Hail: Radar estimated 1\" hail over the parcel; …" } ],,
"fema": [ "…" ], "coverage": { "…": "…" }, "sources": [ "…" ], "notice": "…",
"map": { "radiusMi": 4, "swath": { "swathId": "20250525-ok-001", "date": "2025-05-25", "bandAtPropertyIn": 1, "geojson": { "type": "FeatureCollection", "features": ["…"] } } },
"documents": [
{ "template": "hail_verification", "title": "Hail Verification", "pages": 1, "available": true, "url": "https://api.roofquery.com/api/v1/reports/orders/rq_ord_7f3c2a91d04be615/pdf?template=hail_verification" },
"… wind_verification, date_of_loss, weather_history, homeowner"
]
}
}
| Field | Description |
|---|---|
files | Every file the report has right now: each PDF the date of loss allows, and the map. The same list every report has. |
billing.charged, billing.reason | false and "no_storm_data" when nothing was recorded within 5 miles: the report is free. |
property.parcel.owner | The owner of record from county parcel data: raw as the county wrote it, names as people's names (best effort), type person or organization. null when the parcel has no owner. |
storm.damageChance | The chance of roof damage from the weather near the property, from the worst storm day. High: 1.5"+ hail at or within 1 mile, 75 mph+ wind within 1 mile, or a tornado within ½ mile. Medium: 1"+ hail within 1 mile, 1.5"+ within 3 miles, 58 mph+ wind within 1 mile or 75 mph+ within 3, or a tornado within 1 mile. Low: anything else. Weather-based only; an inspection confirms damage. |
storm.confidence | How sure the data is that the storm reached the property. High: independent sources agree at or very near it. Medium: one strong source. Low: distant or indirect evidence only. |
storm.hail.events | Dates with 0.75"+ hail at the property or within 1 mile, or 1"+ within 3 miles (radar or a ground report), plus the date of loss: the industry's verification-table rule. |
storm.wind.events | Dates with 58 mph+ (the NWS severe threshold) within 3 miles or at a station within 10 miles, 50 mph+ within 1 mile, or wind damage or a tornado within 1 mile, plus the date of loss. |
storm.stormDays | Every day with anything recorded within 5 miles, newest first, with the plain-English evidence. |
storm.dateOfLoss.status | found; nothing_recorded (still gives the nearest storm days before and after); or outside_period. null when no date was given. |
storm.documents | Every PDF for this report. Without a date of loss, date_of_loss has available: false, reason: "needs_date_of_loss" and url: null. |
Codes are lowercase: distances at_property, within_1_mi, within_3_mi,
within_5_mi; ratings low, medium, high; wind sources
measured_gust, estimated_gust, nws_damage_survey,
noaa_storm_events, station.
The same PDF route as roof reports: for a storm report id, template picks one of the
five PDFs, rendered on every request from the stored data. No API key: the order id is
the credential, the same as roof report PDFs, so you can send the link to a customer.
files[] on the report gives a ready link for each (and storm.documents[] says which need a date of loss).
| Template | For | Pages | Example |
|---|---|---|---|
hail_verification | Adjusters: hail at the property and within 1, 3 and 5 miles per date | 1 | Sample PDF |
wind_verification | Wind claims: wind within 1, 3 and 5 miles, stations, warnings, tornadoes | 1 | Sample PDF |
date_of_loss | One claimed date by distance, with the map and the hail history around it. Needs a date of loss. | 2–3 | Sample PDF |
weather_history (default) | Everything: map, summary, hail and wind tables, every storm day with its evidence | 4–7 | Sample PDF |
homeowner | A plain-English page for homeowners with a damage-chance rating and the map | 1 | Sample PDF |
The samples are rendered from the Norman sandbox address with a date of loss of 2025-05-25, branded for a sample company. They open without a key.
| Field | Required | Description |
|---|---|---|
template | No | One of the five above. weather_history when omitted. |
brand | No | A brand profile id. Without it, your account branding. |
brandColor, brandTextColor, companyName, address, publicPhone, publicEmail | No | One-off overrides, as on roof PDFs. |
dateOfLoss, claimNumber, insuredName, notes, reportName | No | This download's details. The Date of Loss template needs a dateOfLoss. |
download | No | 1 downloads instead of opening in the browser. |
curl -o hail.pdf \
"https://api.roofquery.com/api/v1/reports/orders/rq_ord_7f3c2a91d04be615/pdf?template=hail_verification&brand=rq_brand_7f3a21c9d4e07b16"
POST the same path with the parameters as a JSON body when the notes are long (storm
reports only). GET /api/v1/reports/orders/:id/map.png returns the map on its own; the date of loss picks which hail swath is drawn.
The text is never set in your brand colour, so a light brand stays readable.
400 validation for an unknown template, 400 needs_date_of_loss for
the Date of Loss PDF without a date, 404 not_found for an unknown order, and 404 not_available for map.png or a POST on a roof report. Every code is
under Errors.
The file routes take no API key. A completed report's PDF, XML,
ESX (when the order includes it), diagrams and (for Rapid Squares) photo, and a storm
report's PDFs and map, can be fetched by anyone holding the order id — no
Authorization header, nothing to sign.
| Route | Key required |
|---|---|
/orders/:id/pdf | No |
/orders/:id/xml | No |
/orders/:id/esx | No |
/orders/:id/photo (Rapid Squares) | No |
/orders/:id/diagrams and each drawing | No |
/orders/:id/pdf (GET and POST) and /orders/:id/map.png (Storm Reports) | No |
| Everything else — ordering, status, balance, revisions | Yes |
# No -H "Authorization" anywhere. This is the whole request.
curl "https://api.roofquery.com/api/v1/reports/orders/rq_ord_8f3a21c9d4e07b16/pdf" -o report.pdf
This is what lets you hand a report to someone who has no business holding an API key — paste the link into an estimate, email it to a homeowner, embed it in your own portal. The alternative was proxying every download through your server just to attach a header.
The order id is the credential. It is
rq_ord_ plus 8 random bytes — 64 bits, not a sequence, not derived from the
address — so ids can't be guessed or walked. But a URL is not a token: it survives in
browser history, Referer headers, forwarded email and screenshots, in
places an Authorization header never reaches.
There is no expiry and no revocation. Once a link is out, it works for as long as the order exists. Treat a report URL the way you'd treat the report itself — it carries the property address and the full measurements.
Nothing behind these routes can be changed, nothing draws credit, and the response never
says whose account an order belongs to. An id that matches nothing returns
404 not_found.
| Value | Terminal | Meaning |
|---|---|---|
processing | No | Accepted and queued for measurement. First status you'll see. |
in-progress | No | A technician is measuring the property. |
on-hold | No | Paused while something about the property is clarified. Resumes on its own. |
completed | Yes | XML available, measurements populated, files listed. |
updated | — | Event only, never a stored status. A completed report received new geometry; status stays completed. Re-download to pick it up. |
cancelled | No | Reviewed and ended without delivery — obstructed imagery, a property that isn't there, a roof that can't be measured. The cost was refunded as account credit, and reason says why. This is the only failure that is about the address. Not terminal: a cancelled order can occasionally still be delivered, and then moves to completed. |
revisions_requested | No | A revision is open. The previously delivered files stay available. |
revision_completed | — | Event only, never a stored status. The corrected report has been re-delivered and status is back to completed. Re-download to pick it up. |
Insufficient balance and refusals are not statuses — they come back as a
402/502 from the order call before an order exists, so there's nothing to webhook
about. status is always one of the six stored values; updated and
revision_completed only ever arrive as a webhook's event.
statusDetail.code on /status
is the stored status, with terminal resolved for you.
A refusal and a cancellation mean different things, and it's worth branching on
that. An order is never refused at placement because the roof can't be
measured — that is only ever established on review, and reaches you as a
cancelled webhook carrying a reason. So a 502
ORDER_FAILED is a problem our end, not a signal to try a different address:
retry it, and tell us if it persists. A cancelled is the one that really
is about the property.
completed twice
The order's status does not change. It is
completed throughout. A delivered report is a completed report,
however many times it is corrected — updated is never a state an order
sits in, only the name of the event announcing one particular delivery. What alternates
is event, and only event:
| What arrived | status | event | previousStatus |
|---|---|---|---|
| First delivery | completed | completed | in-progress |
| Correction — new measurements | completed | updated | completed |
| Correction — new measurements | completed | completed | updated |
| Correction — new measurements | completed | updated | completed |
| Redelivery of an identical document | no webhook at all | ||
It is the same order throughout — same orderId, same entries in
files[]. Nothing is nested and nothing is superseded: the file URLs render
on demand, so they simply start serving the corrected document.
Treat both events the same way: re-download. The two words carry no
different meaning on a redelivery — the alternation exists so no correction is ever
dropped, because a repeated event is otherwise suppressed as a retry. A second
completed carries a reason saying the report was corrected
again. If you only branch on status, you correctly see one thing the whole
time: a completed report.
An identical redelivery is silent on purpose. The document is compared byte for byte, so nothing reaches you unless your measurements actually changed, even when a report is delivered again unchanged.
Measurements arrive on the order itself, so the webhook body,
GET /orders/:id and
GET /orders/:id/status all
carry the same numbers — you never need a second call to get them. totals
covers the whole property; each entry in structures[] is one building
(house, detached garage, shed) with the identical shape.
| Field | Description |
|---|---|
structureCount | Separate buildings measured. totals is the sum across all of them. Inferred from geometry, not read off the document. |
structures[].index | 1 is the largest building. Ordered by roof area, descending — so structures[0] is the house and outbuildings follow. Stable: the same document always indexes the same way, so an index is safe to store or put in a URL. |
totalAreaSqFt / totalSquares | Sloped roof area. Squares is area ÷ 100. |
facetCount | Number of roof planes. |
predominantPitch | The pitch covering the most area. Flat or N/12. |
pitchAreas | Roof area at each pitch — what you need to price steep-slope labour. |
edgeTotals | Length and count per category: EAVE, RAKE, RIDGE, HIP, VALLEY, STEP_FLASHING, WALL_FLASHING, PARAPET, BEND, OTHER. Always all ten, zero-filled. BEND is a pitch transition — where two different slopes meet; PARAPET is the run of parapet wall along the roof edge. These are the same names the PDF's measurement tables print, so a number you read on the report can be found in the JSON without a lookup. |
totalEdgeLengthFt | Every edge added up. |
hipsAndRidgesLengthFt | Hips and ridges together — ridge cap is ordered against the pair. |
complexity | simple / normal / complex, from facet count and total hip+valley run. |
estimatedAtticSqFt | Sloped area projected back to horizontal. For ventilation and insulation sizing, not a measured floor area. |
diagrams | The four drawings for this target, already addressed. Sits beside the areas on purpose: the moment you have a square footage to show, you have the picture to show with it. |
faces[] | Every roof plane, with its own area, pitch and edge list. |
Edges shared between adjacent faces are counted once per structure, so adding up
faces[].edges[] yourself will overcount — use edgeTotals.
The category names are ours, not the document's. Raw
<LINE> types vary by exporter — the same eave arrives as
FASCIA in one dialect and type="EDGE" subtype="EAVE" in
another, and a pitch transition as BATTEN — so these ten categories are
the normalized form, and they're what the PDF prints too. Parse
edgeTotals rather than joining on raw XML strings. Pitches are snapped to a
reporting ladder (0, ¼, ½, then whole numbers): measured slopes arrive as values like
10.31, and the PDF and these fields round through the same function so
they can't disagree.
Reports carry your brand, or your customer's. Set a company name and the header, footer, cover-page attribution and legal contact block all become that company's; a small RoofQuery watermark remains in the footer. Leave the name blank and the report falls back to full RoofQuery branding. The XML carries no branding at all.
The brand is chosen when you order, and the order keeps it.
| Brand | Set where | Answers |
|---|---|---|
| Account defaults | Portal, Branding → Defaults | "What does my own report look like?" Your brand, license # and logo. Every order placed without a brand. |
| Brand profile | /branding/profiles, or Branding → Profiles | "What does this contractor's report look like?" One saved brand per contractor, logo included, named as brand when placing the order. |
A download can't change an order's brand: every PDF of it carries the brand it was ordered with. A profile is complete — nothing falls through to your defaults — because a half-applied brand prints one company's name over another company's phone number.
# An order for a contractor: its PDF carries Acme Roofing, logo included, on every download
curl -X POST "https://api.roofquery.com/api/v1/reports/orders" \
-H "Authorization: Bearer rq_live_sk_..." -H "Content-Type: application/json" \
-d '{ ..., "brand": "rq_brand_7f3a21c9d4e07b16" }'
Orders placed before 22 October 2026 keep the old rule: a download picks the
brand, with ?brand=<profileId> or per-download params (name, contact block
and colours for one file; most specific wins: params over profile over defaults). See
PDF.
Your own defaults are configured once, by a person who can see the result — whether a colour reads against white, how a wordmark sits in the header. A preview answers that; an endpoint doesn't. Profiles are the opposite case: a platform with two hundred contractors is not going to click through two hundred forms, and the id has to end up on a contractor record in your database, to send with each order. So profiles are an API first and a portal tab second.
There is no field anywhere that takes a URL, and that's deliberate. A linked logo is
somebody else's uptime — a report that renders today comes back logo-less the day their
site moves — and it would mean this API fetching an arbitrary address on your say-so.
Upload the bytes once, to your account or to a profile, and they're ours to serve.
That is also why there is no ?logo= param: a URL is exactly what a logo
must never be.
Every logo is placed on a square canvas automatically. The report header gives the logo a 1:1 slot, so anything that isn't square would otherwise be letterboxed or centre-cropped at render time. Upload whatever shape you have — a wide wordmark, a tall badge, a favicon — and it's scaled to fit and padded with transparency to a 512×512 PNG. Nothing is ever cropped: your proportions are kept exactly, and the padding disappears into the header band. Small logos are scaled up to fill the canvas, so a 64px icon doesn't render as a speck.
Nothing is cached — the PDF is rendered at the moment you request it. Change your defaults or a profile and the reports ordered with them come back updated on their next download.
Revisions are only available on a report that has been delivered.
An order has to be completed (or updated) before there is
anything to correct — asking earlier comes back 409 NOT_REVISABLE.
Revisions are free: a correction to work you already paid for draws no further balance.
One open revision per report. While a revision is open the order sits
in revisions_requested and a second request is refused with
409 REVISION_ALREADY_OPEN. The previously delivered files stay downloadable
the whole time — the correction replaces them when it lands, and the order returns to
completed with a revision_completed webhook. Once it closes,
the order is revisable again.
Sandbox runs the whole cycle, on any completed order. Every sandbox
test address that reaches completed can be revised — there is no special
address for it. Request a revision and you'll get revisions_requested
immediately and revision_completed about 15 seconds later: the same two
webhooks, in the same order, that a live revision sends.
Every error is JSON with an error code and an
error_description. Branch on the code — the description is written for a
person and may change.
{
"error": "validation",
"error_description": "scope is required and must be one of: primary_only, primary_and_garage, all_structures"
}
Match codes case-sensitively, and expect both casings. Lower-case codes
come from the request layer; upper-case ones are raised deeper, in ordering and
revisions. validation and VALIDATION are two distinct codes
that both mean "a field is missing or malformed" — a check for one will not catch the
other.
| Code | HTTP | Meaning |
|---|---|---|
unauthorized | 401 | Missing, invalid, or revoked key |
validation | 400 | A field is missing or malformed |
not_found | 404 | No such order on this account. Also returned for an order belonging to someone else, and for a beta endpoint that is not enabled on this account |
server_error | 500 | Something went wrong on our end. Retry |
| Code | HTTP | Meaning |
|---|---|---|
VALIDATION | 400 | Address or coordinates missing once the request layer has handed off |
INSUFFICIENT_CREDITS | 402 | Available balance won't cover the report (and its ESX, if the order includes one) |
BRAND_NOT_FOUND | 400 | brand isn't one of this account's brand profiles (a typo, or another account's id). Nothing was placed |
ESX_NOT_SUPPORTED | 400 | "esx": true on a report type that has no ESX (Rapid Squares). Nothing was placed; leave esx out or send false |
BILLING_SETUP_REQUIRED | 402 | No card or balance on the account yet |
BRAND_NOT_FOUND | 400 | brand isn't one of this account's brand profiles (a typo, or another account's id). Nothing was placed or charged |
PRODUCT_NOT_ENABLED | 403 | A product that isn't turned on for this account. Nothing was charged |
ORDER_IN_PROGRESS | 409 | An order with this Idempotency-Key is still being placed. Retry in a few seconds |
ENV_MISMATCH | 400 | The key's environment and the order's don't agree. Should be unreachable |
BILLING_NOT_CONFIGURED | 503 | Billing isn't available on our side. Not your account — retry or contact support |
ORDER_FAILED | 502 | The order was refused at placement. Not about the address — a property that can't be measured is only ever discovered on review and arrives later as a cancelled webhook. This is something our end; nothing was charged, and it's worth reporting. Also returned when retrying an Idempotency-Key whose first attempt failed: use a new key |
| Code | HTTP | Meaning |
|---|---|---|
validation | 400 | A field is missing or malformed (a date of loss in the future, an unknown template) |
needs_date_of_loss | 400 | The Date of Loss PDF was asked for without a date. Pass dateOfLoss |
INSUFFICIENT_CREDITS | 402 | Less than $5 available. Nothing was looked up or charged |
BILLING_SETUP_REQUIRED | 402 | Add a card and top up first |
NOT_REVISABLE | 400 | Storm reports aren't revised: read them with new details, or order again |
service_unavailable | 503 | Weather data didn't answer in time. No order was created and nothing was charged; retry |
| Code | HTTP | Meaning |
|---|---|---|
INSUFFICIENT_CREDITS | 402 | Available balance won't cover the ESX. Nothing was charged |
BILLING_SETUP_REQUIRED | 402 | No card or balance on the account yet |
not_found | 404 | No such order on this account |
NOT_UPGRADABLE | 409 | The order was cancelled, so there's no report to add ESX to |
ESX_NOT_SUPPORTED | 409 | A Rapid Squares order: it never has an ESX |
| Code | HTTP | Meaning |
|---|---|---|
VALIDATION | 400 | topic or notes missing or blank |
INVALID_TOPIC | 400 | Topic isn't one of the accepted values |
NOT_REVISABLE | 409 | Revisions need a delivered report — this order isn't completed |
REVISION_ALREADY_OPEN | 409 | One open revision per report |
CLAIM_NOT_FOUND | 409 | The report couldn't be matched for revision. Contact support with the order id |
REVISION_REJECTED | 409 (or 4xx / 502) | The revision was refused. The description says why |
REVISION_FAILED | 502 | The revision couldn't be raised. Safe to retry |
NO_CLAIM | 500 | The order is missing what a revision needs. Contact support with the order id |
| Code | HTTP | Meaning |
|---|---|---|
ESX_NOT_PURCHASED | 403 | The order doesn't include ESX. The message names POST /api/v1/reports/orders/:id/esx and what it costs; price and currency in the body carry it |
ESX_NOT_SUPPORTED | 404 | A Rapid Squares order has no ESX |
not_ready | 409 | Report hasn't completed, so the file doesn't exist yet. Carries the current status |
not_available | 404 | That file doesn't exist for this kind of report: a Rapid Squares order has no diagrams, and only Rapid Squares has a /photo |
no_geometry | 409 | That structure has nothing drawable. Delivered, but there is no sketch to render |
download_failed | 502 | ESX download failed. Safe to retry — nothing was charged |
render_failed | 500 | The PDF or diagram couldn't be drawn |
| Code | HTTP | Meaning |
|---|---|---|
validation | 400 | companyName is missing, an email isn't valid, a colour isn't hex, or text is over its limit |
not_found | 404 | No such profile on this account. Also what a PDF link with an unknown or foreign ?brand= returns, on an order placed before 22 October 2026 |
profile_limit | 409 | The account already has 500 profiles |
BRAND_IN_USE | 409 | Deleting a profile that orders were placed for: their reports keep that brand, so it stays. Edit it instead |
BRAND_IN_USE | 409 | Orders were placed for this profile, so it can't be deleted. Edit it instead |
empty_upload | 400 | The logo body was empty |
too_large | 413 | The logo is over 5MB |
payload_too_large | 413 | The request body is over 6MB |
unsupported_type | 415 | The logo isn't a PNG, JPEG, WebP, GIF, AVIF or SVG |
unreadable_image | 422 | The file couldn't be read as an image |