Documentation

The whole reference, one page

Everything in reading order. For one topic at a time, use the sidebar.

API reference

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.

Get started

New here? Make a sandbox key and follow the Quick start: a free test order, delivered with its webhooks, in about a minute.

MethodPathPurpose
—Quick startKeys, sandbox versus live, and your first order in three calls
GET/api/v1/reports/sandbox/scenariosEvery test address, roof and storm, by report type, and what each does
Account-wide

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.

MethodPathPurpose
GET/api/v1/identityWho the key belongs to, and whether it can order
GET/api/v1/balanceAvailable credit and per-product prices
GET/api/v1/webhooksList your endpoints
GET/api/v1/branding/profilesSaved brands for the contractors you order for
POST/api/v1/branding/profilesCreate a profile
GET/api/v1/branding/profiles/:idOne profile
PATCH/api/v1/branding/profiles/:idChange a profile
DELETE/api/v1/branding/profiles/:idDelete a profile and its logo
PUT/api/v1/branding/profiles/:id/logoUpload the profile's logo
DELETE/api/v1/branding/profiles/:id/logoRemove 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.

Reports
MethodPathPurpose
GET/api/v1/reports/pricingReport products and prices
POST/api/v1/reports/ordersPlace an order
GET/api/v1/reports/orders/:idOrder status and measurements
GET/api/v1/reports/orders/:id/statusThe same order, plus a resolved statusDetail block
POST/api/v1/reports/orders/:id/esxAdd the Xactimate ESX to an order already placed
GET/api/v1/reports/orders/:id/pdfBranded PDF (rendered per request)
GET/api/v1/reports/orders/:id/xmlThe measurement document as XML
GET/api/v1/reports/orders/:id/esxThe same report as an Xactimate .esx, on orders that include it
GET/api/v1/reports/orders/:id/diagramsEvery diagram available for the order, already addressed
GET/api/v1/reports/orders/:id/diagrams/:structure/:typeOne roof drawing, as SVG or PNG
POST/api/v1/reports/orders/:id/revisionsRequest a revision on a completed report
GET/api/v1/reports/orders/:id/revisionsRevision history for one order
GET/api/v1/reports/revision-optionsValid revision topics and rules
Rapid Squares
MethodPathPurpose
POST/api/v1/reports/ordersOrder one, with "reportType": "rapid_squares"
GET/api/v1/reports/orders/:id/photoThe photo the outlines were traced on (no key)
Storm Reports Open beta
MethodPathPurpose
—Storm ReportsWhat a storm report is, its price and coverage
POST/api/v1/reports/ordersOrder one, with "reportType": "storm"; the response is the report
GET/api/v1/reports/orders/:idThe report, with this read's date of loss and notes
—Storm report dataEvery field of a storm report, damage chance and confidence
GET/api/v1/reports/orders/:id/pdfOne of the five PDFs, by template (no key)
GET/api/v1/reports/orders/:id/map.pngThe map on its own (no key)
Concepts
TopicWhat it covers
Sharing a reportThe file routes take no key — what that means for a link
Order lifecycleEvery status and event, and which statuses are terminal
Report dataThe totals and structures[] field reference
White labelingYour brand, or a contractor's, on the PDF, chosen when ordering
RevisionsWhen a report can be corrected, and what it costs
ErrorsEvery error code the API returns
Quick start

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.

Get a key

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.

KeyWhat 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 a webhook endpoint

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.

Your first order, in three calls

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.

Where next
  • 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.
GET /api/v1/reports/sandbox/scenarios

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.

How the sandbox works
  • 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 of rq_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.
Test addresses
Report typeScenarioAddressWhat happens
Residential, Rapid Squareshappy_path6515 Turnbridge Pl, Prospect, KY 40059processing 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.
Residentialhappy_path_2_structures725 Pleasant Ave, St Paul Park, MN 55071Same timeline. Two structures: a house with an attached wing, and a detached shed.
Residential, Rapid Squareshappy_path_4_structures401 Garrison St, Bulpitt, IL 62517Same timeline. Four structures: a house and three outbuildings.
Commercialhappy_path_commercial6101 E Lombard St, Baltimore, MD 21224Same timeline. A low-slope commercial roof with parapet walls.
Residential, Rapid Squareson_hold12440 S Nimitz Dr, Galveston, IN 46932processing 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 Squarescancelled600 N Michigan Ave, Chicago, IL 60611, and any other addressprocessing at 0 s, in-progress at 10 s, cancelled at 20 s. Nothing is delivered.
Residential, Commercial, Rapid Squarespayment_failed400 5th Ave, New York, NY 10018The order call itself returns 402 INSUFFICIENT_CREDITS. No order is created and no webhook fires.
Residential, Commercial, Rapid Squaresorder_failed500 Hollywood Blvd, Los Angeles, CA 90028The order call itself returns 502 ORDER_FAILED. No order is created and no webhook fires.
Stormstorm_busy3208 Riviera Dr, Norman, OK 73072, and any other addressA 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.
Stormstorm_hail1816 Copper Creek Dr, Plano, TX 75075Hail-led: 3" hail within 1 mile
Stormstorm_wind112 Regency Pkwy, Omaha, NE 68114Wind-led: severe wind on 12 days; the owner is a company
Stormstorm_canada237 8 Ave SE, Calgary, AB T2G 5C3Canada: radar hail and stations only, with the coverage note
Stormstorm_none157151 US-101, Forks, WA 98331 (Kalaloch Lodge)Nothing recorded: the free report (billing.reason: "no_storm_data", amount: 0)
Stormpayment_failed400 5th Ave, New York, NY 10018402 INSUFFICIENT_CREDITS, no order
Stormservice_unavailable500 Hollywood Blvd, Los Angeles, CA 90028503 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.

Revisions

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.

Parameters
FieldRequiredDescription
reportTypeNoQuery parameter: residential, commercial, rapid_squares or storm lists only that type's addresses. Without it, every address. Anything else is 400 validation.
Request
curl -H "Authorization: Bearer rq_test_sk_..." \
  "https://api.roofquery.com/api/v1/reports/sandbox/scenarios?reportType=residential"
Response

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.

GET /api/v1/identity

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".

Request
curl -H "Authorization: Bearer rq_live_sk_..." \
  "https://api.roofquery.com/api/v1/identity"
Response
{
  "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.

GET /api/v1/balance

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.

Request
curl -H "Authorization: Bearer rq_live_sk_..." \
  "https://api.roofquery.com/api/v1/balance"
Response
{
  "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.

GET /api/v1/webhooks

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.

Request
curl -H "Authorization: Bearer rq_live_sk_..." \
  "https://api.roofquery.com/api/v1/webhooks"
Response
{
  "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.

Brand profiles

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.

MethodPathDescription
GET/api/v1/branding/profilesEvery profile on the account, newest first
POST/api/v1/branding/profilesCreate. Returns 201 with the profile and its id
GET/api/v1/branding/profiles/:idOne profile
PATCH/api/v1/branding/profiles/:idChange any text or colour field. Omitted fields are left alone
DELETE/api/v1/branding/profiles/:idDelete 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/logoUpload the logo: raw image bytes as the body, with an image Content-Type
DELETE/api/v1/branding/profiles/:id/logoRemove the logo. The rest of the profile stays
Parameters

The JSON body of POST and PATCH.

FieldRequiredDescription
companyNameTo createPrints on the header band, footer and cover attribution, and is how the profile is listed. 80 chars
licenseNumberNoContact block, as License # …. 40 chars
contactEmailNoNever printed. Who we email about a property. 160 chars
contactPhoneNoNever printed. Who we call about a property. 32 chars
addressNoContact block. 160 chars
publicPhoneNoContact block. 32 chars. A 10-digit number is formatted (502) 555-0100
publicEmailNoContact block. 160 chars
brandColorNoHex. Defaults to RoofQuery blue
brandTextColorNoHex. 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.

Request
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"
  }'
Response
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.

Errors

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.

GET /api/v1/reports/pricing

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.

Request
curl -H "Authorization: Bearer rq_live_sk_..." \
  "https://api.roofquery.com/api/v1/reports/pricing"
Response
{
  "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.

POST /api/v1/reports/orders

Places an order and returns 201 with the order in its initial state. Measurements arrive later — wait for the completed webhook rather than polling.

Parameters
FieldRequiredDescription
countryYesUS or CA. Validated first — every other field depends on it
streetYesstring
cityYesstring
stateYes2-letter state or province abbreviation
postalCodeYesUS 12345 or 12345-6789; CA A1A 1A1. Format only — we don't check it agrees with the state
latitudeYesnumber, −90 to 90
longitudeYesnumber, −180 to 180
scopeYesprimary_only, primary_and_garage, or all_structures. Not defaulted
reportTypeNoresidential (default), commercial, rapid_squares (Rapid Squares), or storm (Storm Reports, which take their own fields)
esxNotrue 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
brandNoThe 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
claimNoClaim 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
notesNoFree text for the technician
Retrying safely

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.

Request
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."
  }'
Response

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.

Errors

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.

GET /api/v1/reports/orders/:id

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.

Request
curl -H "Authorization: Bearer rq_live_sk_..." \
  "https://api.roofquery.com/api/v1/reports/orders/rq_ord_8f3a21c9d4e07b16"
Response

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.

Errors

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.

GET /api/v1/reports/orders/:id/status

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.

Request
curl -H "Authorization: Bearer rq_live_sk_..." \
  "https://api.roofquery.com/api/v1/reports/orders/rq_ord_8f3a21c9d4e07b16/status"
Response
{
  "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": []
}
FieldDescription
terminalNothing further is expected on this order
filesReadyThe 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
knownfalse if we add a status your code hasn't seen. Branch on this rather than on an exhaustive switch
changedAtWhen the order last moved
estimatedReadyAtOrder time plus the product's typical turnaround. null once files are ready
openRevisionIdThe 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.

POST /api/v1/reports/orders/:id/esx

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.

Parameters

None, and no request body. There's no need for an Idempotency-Key: a repeat call is already safe.

Request
curl -X POST "https://api.roofquery.com/api/v1/reports/orders/rq_ord_8f3a21c9d4e07b16/esx" \
  -H "Authorization: Bearer rq_live_sk_..."
Response

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.

Errors
CodeHTTPMeaning
INSUFFICIENT_CREDITS402Available balance won't cover the ESX. Nothing was charged
BILLING_SETUP_REQUIRED402No card or balance on the account yet
not_found404No such order on this account
NOT_UPGRADABLE409The order was cancelled
ESX_NOT_SUPPORTED409A Rapid Squares order: it never has an ESX

Every code is under Errors.

GET /api/v1/reports/orders/:id/pdf

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.

Parameters

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.

FieldRequiredDescription
downloadNo1 forces a save dialog. Without it the PDF is served inline, so you can iframe it
brandNoOrders placed before 22 October 2026: a brand profile id. The report renders in that customer's brand, logo included
companyNameNoName on the header band, footer and cover attribution. Up to 80 characters
addressNoFirst line of the contact block, under the name. Up to 160 characters
publicPhoneNoContact block. A 10-digit number is formatted (502) 555-0100; anything else prints as sent. Up to 32 characters
publicEmailNoContact block. Up to 160 characters
brandColorNoHex, #RGB through #RRGGBBAA. URL-encode the # as %23
brandTextColorNoHex. 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.

Request
curl "https://api.roofquery.com/api/v1/reports/orders/rq_ord_8f3a21c9d4e07b16/pdf?download=1" \
  -o report.pdf
Response

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
Errors

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.

GET /api/v1/reports/orders/:id/xml

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.

Request
curl "https://api.roofquery.com/api/v1/reports/orders/rq_ord_8f3a21c9d4e07b16/xml" \
  -o report.xml
Response
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.

Errors

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.

GET /api/v1/reports/orders/:id/esx

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.

Parameters

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.

Request
curl "https://api.roofquery.com/api/v1/reports/orders/rq_ord_8f3a21c9d4e07b16/esx" \
  -o report.esx
Response
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.

Working with the file

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.

The filename, given the claim on the order
Claim on the orderFilenameWhy
CLAIM12345CLAIM12345.esxAlready legal — used untouched
123 Main St/Apt#4123_Main_St_Apt.esxSpace, slash and hash substituted; runs collapsed; trailing separator trimmed
12/34 Main Street Apt #712_34_Main_Stree.esxSame, then cut at 16
none, on 6515 Turnbridge Pl6515_Turnbridge.esxThe 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.

Errors

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.

GET /api/v1/reports/orders/:id/diagrams

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.

Request
curl "https://api.roofquery.com/api/v1/reports/orders/rq_ord_8f3a21c9d4e07b16/diagrams"
Response
{
  "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" }
    }
  ]
}
Errors

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.

GET /api/v1/reports/orders/:id/diagrams/:structure/:type

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.

Parameters
FieldRequiredDescription
:structureYes, in the pathall, 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
:typeYes, in the pathoutline, area, pitch, lengths
formatNosvg (default) or png
sizeNoEdge length in pixels. Default 1000, min 200, max 4000. Always square
downloadNo1 forces a save dialog instead of inline
Request
curl "https://api.roofquery.com/api/v1/reports/orders/rq_ord_8f3a21c9d4e07b16/diagrams/1/area?format=png&size=2000" \
  -o structure-1-area.png
Response
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.

Errors

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.

POST /api/v1/reports/orders/:id/revisions

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.

Parameters
FieldRequiredDescription
topicYesOne of the values from /reports/revision-options
notesYesTrimmed 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
Request
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."}'
Response

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", "...": "" } ]
  }
}
Errors

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.

GET /api/v1/reports/orders/:id/revisions

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.

Request
curl -H "Authorization: Bearer rq_live_sk_..." \
  "https://api.roofquery.com/api/v1/reports/orders/rq_ord_8f3a21c9d4e07b16/revisions"
Response
{
  "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"
    }
  ]
}
GET /api/v1/reports/revision-options

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.

Request
curl -H "Authorization: Bearer rq_live_sk_..." \
  "https://api.roofquery.com/api/v1/reports/revision-options"
Response
{
  "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.

TopicUse it when
Roof Has Wrong PitchOne or more pitches are wrong.
Missing StructuresA building on the property isn't in the report.
Wrong StructureThe report includes something it shouldn't.
Measurements Are OffAreas or lengths don't match the property.
OtherAnything else — explain it in notes.
Rapid Squares

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.

Delivered in under 30 minutes. Need it faster? Email [email protected] and we may be able to work something out.
WhatHow
OrderPOST /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.
DeliveryUnder 30 minutes, then a completed webhook as for any report.
FilesA 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).
PitchOne estimated pitch per building. The table gives every other pitch too, so you can use your own.
Request
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"
  }'
Response

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>
GET /api/v1/reports/orders/:id/photo

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.

Storm Reports Open beta

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.

Open beta. Storm Reports are available to every account and billed as normal, but the report format and the JSON data structure may change while the product is in beta. Read 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.
WhatDetail
Price$5 from your balance. $0 when nothing was recorded within 5 miles (billing.reason: "no_storm_data").
SpeedAbout 2 seconds. The response is the report, and a completed webhook goes out too, like every report.
PeriodThe last five years. Radar hail goes back to October 2020.
CoverageUS and Canada. Canada has radar hail and weather stations, but no public archive of storm reports or warnings, and the report says so.
DataStored when you order and never changed. Reading the report or downloading a PDF a year later shows exactly the same data.
PDFsFive 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

POST /api/v1/reports/orders (storm) Open beta

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.

Parameters
FieldRequiredDescription
reportTypeYes"storm". Without it the order is a roof report.
street, city, state, postalCode, countryYesStrings. The same address rules as roof orders; country is US or CA.
latitude, longitudeYesNumbers, in decimal degrees.
dateOfLossNoYYYY-MM-DD. Highlights that date and enables the Date of Loss PDF. Can't be in the future.
claimNoUp to 64 characters. Printed on the report.
insuredNameNoUp to 120 characters. Printed on the report.
notesNoUp 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.
reportNameNoUp to 300 characters. Your own label.
Request
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."
  }'
Response

201 Created with the whole report: every field is under Storm report data.

Errors

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.

GET /api/v1/reports/orders/:id (storm) Open beta

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.

Request
curl -H "Authorization: Bearer rq_live_sk_..." \
  "https://api.roofquery.com/api/v1/reports/orders/rq_ord_7f3c2a91d04be615?dateOfLoss=2024-11-02"
Response

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.

Storm report data Open beta

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).

Open beta: fields may be added, renamed or restructured while Storm Reports are in beta. Check 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"
    ]
  }
}
Fields that need explaining
FieldDescription
filesEvery 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.reasonfalse and "no_storm_data" when nothing was recorded within 5 miles: the report is free.
property.parcel.ownerThe 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.damageChanceThe 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.confidenceHow 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.eventsDates 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.eventsDates 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.stormDaysEvery day with anything recorded within 5 miles, newest first, with the plain-English evidence.
storm.dateOfLoss.statusfound; nothing_recorded (still gives the nearest storm days before and after); or outside_period. null when no date was given.
storm.documentsEvery 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.

GET /api/v1/reports/orders/:id/pdf (storm) Open beta

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).

Templates
TemplateForPagesExample
hail_verificationAdjusters: hail at the property and within 1, 3 and 5 miles per date1Sample PDF
wind_verificationWind claims: wind within 1, 3 and 5 miles, stations, warnings, tornadoes1Sample PDF
date_of_lossOne claimed date by distance, with the map and the hail history around it. Needs a date of loss.2–3Sample PDF
weather_history (default)Everything: map, summary, hail and wind tables, every storm day with its evidence4–7Sample PDF
homeownerA plain-English page for homeowners with a damage-chance rating and the map1Sample 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.

Parameters
FieldRequiredDescription
templateNoOne of the five above. weather_history when omitted.
brandNoA brand profile id. Without it, your account branding.
brandColor, brandTextColor, companyName, address, publicPhone, publicEmailNoOne-off overrides, as on roof PDFs.
dateOfLoss, claimNumber, insuredName, notes, reportNameNoThis download's details. The Date of Loss template needs a dateOfLoss.
downloadNo1 downloads instead of opening in the browser.
Request
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.

Errors

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.

Sharing a report

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.

RouteKey required
/orders/:id/pdfNo
/orders/:id/xmlNo
/orders/:id/esxNo
/orders/:id/photo (Rapid Squares)No
/orders/:id/diagrams and each drawingNo
/orders/:id/pdf (GET and POST) and /orders/:id/map.png (Storm Reports)No
Everything else — ordering, status, balance, revisionsYes
# 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.

What that means, said plainly

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.

Order lifecycle
ValueTerminalMeaning
processingNoAccepted and queued for measurement. First status you'll see.
in-progressNoA technician is measuring the property.
on-holdNoPaused while something about the property is clarified. Resumes on its own.
completedYesXML 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.
cancelledNoReviewed 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_requestedNoA 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.

Redeliveries, and why you may see 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 arrivedstatuseventpreviousStatus
First deliverycompletedcompletedin-progress
Correction — new measurementscompletedupdatedcompleted
Correction — new measurementscompletedcompletedupdated
Correction — new measurementscompletedupdatedcompleted
Redelivery of an identical documentno 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.

Report data

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.

FieldDescription
structureCountSeparate buildings measured. totals is the sum across all of them. Inferred from geometry, not read off the document.
structures[].index1 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 / totalSquaresSloped roof area. Squares is area ÷ 100.
facetCountNumber of roof planes.
predominantPitchThe pitch covering the most area. Flat or N/12.
pitchAreasRoof area at each pitch — what you need to price steep-slope labour.
edgeTotalsLength 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.
totalEdgeLengthFtEvery edge added up.
hipsAndRidgesLengthFtHips and ridges together — ridge cap is ordered against the pair.
complexitysimple / normal / complex, from facet count and total hip+valley run.
estimatedAtticSqFtSloped area projected back to horizontal. For ventilation and insulation sizing, not a measured floor area.
diagramsThe 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.

White labeling

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.

BrandSet whereAnswers
Account defaultsPortal, 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.

Why the defaults are a setting and profiles are an API

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.

The logo is a file, not a link

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

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.

Errors

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.

Any endpoint
CodeHTTPMeaning
unauthorized401Missing, invalid, or revoked key
validation400A field is missing or malformed
not_found404No 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_error500Something went wrong on our end. Retry
Placing an order
CodeHTTPMeaning
VALIDATION400Address or coordinates missing once the request layer has handed off
INSUFFICIENT_CREDITS402Available balance won't cover the report (and its ESX, if the order includes one)
BRAND_NOT_FOUND400brand isn't one of this account's brand profiles (a typo, or another account's id). Nothing was placed
ESX_NOT_SUPPORTED400"esx": true on a report type that has no ESX (Rapid Squares). Nothing was placed; leave esx out or send false
BILLING_SETUP_REQUIRED402No card or balance on the account yet
BRAND_NOT_FOUND400brand isn't one of this account's brand profiles (a typo, or another account's id). Nothing was placed or charged
PRODUCT_NOT_ENABLED403A product that isn't turned on for this account. Nothing was charged
ORDER_IN_PROGRESS409An order with this Idempotency-Key is still being placed. Retry in a few seconds
ENV_MISMATCH400The key's environment and the order's don't agree. Should be unreachable
BILLING_NOT_CONFIGURED503Billing isn't available on our side. Not your account — retry or contact support
ORDER_FAILED502The 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
Storm reports
CodeHTTPMeaning
validation400A field is missing or malformed (a date of loss in the future, an unknown template)
needs_date_of_loss400The Date of Loss PDF was asked for without a date. Pass dateOfLoss
INSUFFICIENT_CREDITS402Less than $5 available. Nothing was looked up or charged
BILLING_SETUP_REQUIRED402Add a card and top up first
NOT_REVISABLE400Storm reports aren't revised: read them with new details, or order again
service_unavailable503Weather data didn't answer in time. No order was created and nothing was charged; retry
Adding ESX to an order
CodeHTTPMeaning
INSUFFICIENT_CREDITS402Available balance won't cover the ESX. Nothing was charged
BILLING_SETUP_REQUIRED402No card or balance on the account yet
not_found404No such order on this account
NOT_UPGRADABLE409The order was cancelled, so there's no report to add ESX to
ESX_NOT_SUPPORTED409A Rapid Squares order: it never has an ESX
Revisions
CodeHTTPMeaning
VALIDATION400topic or notes missing or blank
INVALID_TOPIC400Topic isn't one of the accepted values
NOT_REVISABLE409Revisions need a delivered report — this order isn't completed
REVISION_ALREADY_OPEN409One open revision per report
CLAIM_NOT_FOUND409The report couldn't be matched for revision. Contact support with the order id
REVISION_REJECTED409 (or 4xx / 502)The revision was refused. The description says why
REVISION_FAILED502The revision couldn't be raised. Safe to retry
NO_CLAIM500The order is missing what a revision needs. Contact support with the order id
Files and diagrams
CodeHTTPMeaning
ESX_NOT_PURCHASED403The 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_SUPPORTED404A Rapid Squares order has no ESX
not_ready409Report hasn't completed, so the file doesn't exist yet. Carries the current status
not_available404That file doesn't exist for this kind of report: a Rapid Squares order has no diagrams, and only Rapid Squares has a /photo
no_geometry409That structure has nothing drawable. Delivered, but there is no sketch to render
download_failed502ESX download failed. Safe to retry — nothing was charged
render_failed500The PDF or diagram couldn't be drawn
Brand profiles
CodeHTTPMeaning
validation400companyName is missing, an email isn't valid, a colour isn't hex, or text is over its limit
not_found404No 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_limit409The account already has 500 profiles
BRAND_IN_USE409Deleting a profile that orders were placed for: their reports keep that brand, so it stays. Edit it instead
BRAND_IN_USE409Orders were placed for this profile, so it can't be deleted. Edit it instead
empty_upload400The logo body was empty
too_large413The logo is over 5MB
payload_too_large413The request body is over 6MB
unsupported_type415The logo isn't a PNG, JPEG, WebP, GIF, AVIF or SVG
unreadable_image422The file couldn't be read as an image