Base URL: https://api.roofquery.com. Authenticate with Authorization: Bearer <your key> — except the four file routes, 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.
One API key and one set of webhook endpoints cover every product, and every product draws on the same balance. 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 |
Branding is not on this list. Your defaults — company name, contact
block, colours and logo — are set in the portal under
Report branding. What the API does
take is per-download branding: pass
companyName, brandColor and brandTextColor as
query params on the PDF URL to brand a single
report for one of your own customers. 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 |
| 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 |
| 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 |
| GET | /api/v1/reports/sandbox/scenarios | Sandbox addresses and timelines |
| Topic | What it covers |
|---|---|
| Sharing a report | The file routes take no key — what that means for a link |
| Order lifecycle | Every status, and which of them are terminal |
| Report data | The totals and structures[] field reference |
| White labeling | Putting your brand on the PDF, account-wide or per request |
| Revisions | When a report can be corrected, and what it costs |
| Errors | Every error code the API returns |
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": "acct_9f2c1",
"companyName": "Acme Roofing",
"sandbox": false,
"env": "live",
"keyId": "key_3d81aa",
"balance": {
"available": 247,
"currency": "usd",
"canOrder": true
}
}
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": 13, "commercial": 35 }
},
"residentialReportsRemaining": 19
}
residentialReportsRemaining is a convenience quoted at the residential
rate. A commercial report costs more, so treat it as an upper bound.
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 product. Read-only: subscriptions are created in the portal, not through the API, so a leaked key can't quietly redirect your event stream.
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": [
{
"id": "wh_41c8e2",
"url": "https://acme.example.com/hooks/roofquery",
"env": "live",
"active": true,
"createdAt": "2026-06-02T09:14:00.000Z"
}
]
}
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.
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": 13,
"listPrice": 13,
"custom": false,
"format": "xml",
"estimatedDeliveryHours": 2
},
{
"id": "commercial",
"label": "Commercial Roof Report",
"price": 35,
"listPrice": 35,
"custom": false,
"format": "xml",
"estimatedDeliveryHours": 8
}
],
"sandbox": false,
"_note": null
}
With a sandbox key, sandbox is true and _note
explains that these are the prices a live order would cost.
id is the value you pass as reportType when ordering.
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 | Rules |
|---|---|---|
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) or commercial |
reportName | No | Your own label, printed on the report |
claim | No | Claim number for the job, up to 64 characters. The annotators put it 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 |
clientInfo | No | Object. Only firstName, lastName, email, phone, street, city, state, county are kept — anything else is dropped. 200 chars each |
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": "primary_and_garage",
"reportType": "residential",
"reportName": "Henderson — 6515 Turnbridge",
"claim": "CLAIM12345",
"notes": "Detached garage at rear.",
"clientInfo": {
"firstName": "Dana",
"lastName": "Henderson",
"email": "[email protected]",
"phone": "502-555-0148"
}
}'
201 Created. totals, structures and files are empty until the report is delivered.
{
"orderId": "rq_ord_8f3a21c9",
"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",
"name": "Henderson — 6515 Turnbridge",
"claim": "CLAIM12345",
"scope": "primary_and_garage",
"scopeLabel": "Primary structure and detached garage",
"notes": "Detached garage at rear.",
"format": "xml"
},
"clientInfo": {
"firstName": "Dana",
"lastName": "Henderson",
"email": "[email protected]",
"phone": "502-555-0148"
},
"billing": {
"amount": 13,
"currency": "usd",
"method": "account_balance",
"holdReference": "rq_ord_8f3a21c9",
"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 never contacts the provider. 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.
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_8f3a21c9"
Delivered order, trimmed — structures[].faces[] continues for every facet:
{
"orderId": "rq_ord_8f3a21c9",
"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",
"scope": "primary_and_garage",
"scopeLabel": "Primary structure and detached garage",
"format": "xml"
},
"billing": {
"amount": 13, "currency": "usd", "method": "account_balance",
"holdReference": "rq_ord_8f3a21c9", "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 },
"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_8f3a21c9/diagrams/all/outline",
"area": "https://api.roofquery.com/api/v1/reports/orders/rq_ord_8f3a21c9/diagrams/all/area",
"pitch": "https://api.roofquery.com/api/v1/reports/orders/rq_ord_8f3a21c9/diagrams/all/pitch",
"lengths": "https://api.roofquery.com/api/v1/reports/orders/rq_ord_8f3a21c9/diagrams/all/lengths"
}
},
"structures": [
{
"index": 1,
"facetCount": 33,
"totalAreaSqFt": 5597.29,
"totalSquares": 55.97,
"predominantPitch": "12/12",
"edgeTotals": { "...": "same eight 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_8f3a21c9/pdf" },
{ "name": "report.xml", "url": "https://api.roofquery.com/api/v1/reports/orders/rq_ord_8f3a21c9/xml" },
{ "name": "report.esx", "url": "https://api.roofquery.com/api/v1/reports/orders/rq_ord_8f3a21c9/esx" }
]
}
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_8f3a21c9/status"
{
"orderId": "rq_ord_8f3a21c9",
"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 | What it is |
|---|---|
terminal | Nothing further is expected on this order |
filesReady | The PDF, XML and ESX can all 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.
The white-labeled report, rendered at the moment you request it. Nothing is stored and nothing is cached, so changing your branding re-skins every report you've already ordered on its next download. No API key required — see Sharing a report.
| Query | Effect | Max |
|---|---|---|
?download=1 | Forces a save dialog. Omit it and the PDF is served inline, so you can iframe it | — |
?companyName= | Name on the header band, footer and cover attribution | 80 |
?address= | First line of the contact block, under the name | 160 |
?publicPhone= | Contact block. A 10-digit number is formatted (502) 555-0100; anything else prints as sent | 32 |
?publicEmail= | Contact block | 160 |
?brandColor= | Hex — URL-encode the # as %23 | — |
?brandTextColor= | Text on the header band | — |
These params are the only way to set branding through the API. Your account defaults — company name, contact block, colours, logo — are configured in the portal under Report branding, and these override them for one download. Anything you don't pass falls through to the saved value.
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.
A colour that isn't a hex string returns 400 validation rather than
rendering something strange — #RGB through #RRGGBBAA are all
accepted. Text over the length in the table is refused the same way rather than
silently cut, because these render in a fixed-height header. There is no logo param: a
logo is bytes, not something that fits in a URL, so every render uses the file on your
account.
curl "https://api.roofquery.com/api/v1/reports/orders/rq_ord_8f3a21c9/pdf?download=1" \
-o report.pdf
Returns 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_8f3a21c9.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"
}
The per-request overrides exist so you can brand a report for one of your
customers without touching your account settings. There is no logo query
parameter — a logo is bytes, not a link. See
White labeling.
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_8f3a21c9/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_8f3a21c9.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>
409 not_ready until the order completes, same shape as the PDF route.
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.
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.
Free. It is another way to download a report you have already paid for, not a second product, and it draws no balance.
Give the order a claim on
POST /reports/orders
and the annotators put it on the .esx. The file downloads as
<claim>.esx, so it lands under the number your estimator already
knows the job by. This endpoint takes no parameters — 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_8f3a21c9/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.
Served as application/octet-stream. We check the file before sending it,
so a bad one comes back as a 502 download_failed rather than as a
.esx that fails to open.
| 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. 409 not_ready until the
order completes; 502 download_failed if the download failed — safe to
retry, nothing was charged.
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_8f3a21c9/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_8f3a21c9/diagrams/all/outline",
"area": "https://api.roofquery.com/api/v1/reports/orders/rq_ord_8f3a21c9/diagrams/all/area",
"pitch": "https://api.roofquery.com/api/v1/reports/orders/rq_ord_8f3a21c9/diagrams/all/pitch",
"lengths": "https://api.roofquery.com/api/v1/reports/orders/rq_ord_8f3a21c9/diagrams/all/lengths"
}
},
{
"structure": 1,
"label": "Structure 1",
"totalAreaSqFt": 5597.29,
"diagrams": { "...": "same four types, addressed to structure 1" }
}
]
}
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.
| Parameter | Values |
|---|---|
:structure | 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 | outline, area, pitch, lengths |
?format= | svg (default) or png |
?size= | Edge length in pixels. Default 1000, min 200, max 4000. Always square |
?download=1 | Forces a save dialog instead of inline |
curl "https://api.roofquery.com/api/v1/reports/orders/rq_ord_8f3a21c9/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_8f3a21c9-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 or a :structure the order
doesn't have returns 400; an undelivered order returns
409 not_ready.
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 | Rules |
|---|---|---|
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_8f3a21c9/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_rq_rev_2b91f4c07e13c07e13",
"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_8f3a21c9",
"status": "revisions_requested",
"files": [ "...still the previously delivered report" ],
"revisions": [ { "revisionId": "rq_rq_rev_2b91f4c07e13c07e13", "status": "open", "...": "" } ]
}
}
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_8f3a21c9/revisions"
{
"revisions": [
{
"revisionId": "rq_rq_rev_2b91f4c07e13c07e13",
"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 provider was briefly unreachable — 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 canonical test addresses, published in-band so you can discover them without leaving
the API. Order one of these with a rq_test_sk_* key to trigger a specific
timeline. Matching is case-insensitive, and coordinates within ~110m match too.
Any address not on this list runs the cancelled timeline —
there is no address that turns a sandbox order into a real one.
curl -H "Authorization: Bearer rq_test_sk_..." \
"https://api.roofquery.com/api/v1/reports/sandbox/scenarios"
{
"note": "Sandbox keys (rq_test_sk_*) only. The address you order is what picks the scenario — exactly, or by coordinates within ~110m. Any address not listed here runs the `cancelled` timeline.",
"revisions": "Any 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.",
"scenarios": [
{
"scenario": "happy_path",
"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": "payment_failed",
"address": "400 5th Ave, New York, NY 10018, USA",
"latitude": 40.751,
"longitude": -73.985,
"timeline": [],
"synchronousFailure": true
}
]
}
synchronousFailure: true means the order call itself fails — no order row
is created and no webhook ever fires, because in production that failure comes back
before an order exists. Every other scenario returns 201 and then works
through its timeline. Webhooks are held back about 5 seconds after each transition, so
the HTTP response to the call that caused a change always beats the webhook announcing
it.
Each address delivers a genuinely different roof — recorded from a real property and run through the same parser and PDF path a live order uses. The Sandbox page lists them with the PDF, the XML and the ESX for each, so you can see what an address returns without ordering it. The XML is the one to pull if you're writing a parser — it's the same document a live order delivers.
The four file routes take no API key. A completed report's PDF, XML,
ESX and diagrams 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/diagrams and each drawing | 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. |
revisions_requested | No | A revision is open. The previously delivered files stay available. |
revision_completed | Yes | The corrected report has been re-delivered. |
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. The same eight values appear as statusDetail.code, 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 two 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 — the provider does sometimes resend a file 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 | What it is |
|---|---|
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 nine 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. Set a company name and the header, footer, cover-page attribution and legal contact block all become yours; a small RoofQuery watermark remains in the footer. Leave it blank and the report falls back to full RoofQuery branding.
Two ways to supply it, and they answer different questions. Account-wide — your defaults, set once in the portal under Report branding. Every report uses them unless a download says otherwise. Per request — pass query params on the PDF URL to override the saved values for a single download, which is how you brand a report for one of your customers without touching your account settings.
There is no /branding endpoint. Defaults are settings, and
settings are configured once by a person who can see the result — whether a colour reads
against white, how a wordmark sits in the header, where the contact block wraps. A
preview answers that; an endpoint doesn't, and a second place to set the same values is
a second place for them to be wrong. The per-download params below are the part an API
is genuinely better at, and they stay.
curl "https://api.roofquery.com/api/v1/reports/orders/rq_ord_8f3a21c9/pdf\
?companyName=Acme%20Roofing\
&address=900%20Bardstown%20Rd%2C%20Louisville%2C%20KY%2040204\
&publicPhone=5025559876\
&publicEmail=estimates%40acmeroofing.example\
&brandColor=%230F7A4D" \
-o acme-report.pdf
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 your site moves — and it would mean this API fetching an arbitrary address on your say-so. Upload the bytes once and they're ours to serve.
The logo is set in the portal, under Report branding — there is no API endpoint for it. It's a once-per-account thing, and one you want to look at: how a wide wordmark sits in a square slot, where the transparent padding lands, whether a small icon survives being scaled up. A file picker with a preview answers that. Raw bytes posted at an endpoint don't. It is also the one piece of branding a per-download param can't override: a logo is bytes, not something you can put in a URL, so every render uses the file on the account.
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 branding and every report you've already ordered comes back re-skinned on its next download. The XML file carries no branding at all.
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 |
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 |
BILLING_SETUP_REQUIRED | 402 | No card or balance on the account yet |
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 |
| 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 |
REVISION_REJECTED | 409 | The revision was refused. The description says why |
REVISION_FAILED | 502 | The revision couldn't be raised. Safe to retry |
| Code | HTTP | Meaning |
|---|---|---|
not_ready | 409 | Report hasn't completed, so the file doesn't exist yet. Carries the current status |
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 |