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

Account-wide

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.

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 your own customers
POST/api/v1/branding/profilesCreate a profile
PATCH/api/v1/branding/profiles/:idChange a profile
PUT/api/v1/branding/profiles/:id/logoUpload the profile's logo
DELETE/api/v1/branding/profiles/:idDelete a profile and its logo

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 your customers' brands: a profile per customer, logo included, named on the PDF URL as ?brand=, plus one-off query params for a single download. 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
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
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
GET/api/v1/reports/sandbox/scenariosSandbox addresses and timelines
Concepts
TopicWhat it covers
Sharing a reportThe file routes take no key — what that means for a link
Order lifecycleEvery status, and which of them are terminal
Report dataThe totals and structures[] field reference
White labelingPutting your brand on the PDF, account-wide or per request
RevisionsWhen a report can be corrected, and what it costs
ErrorsEvery error code the API returns
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".

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

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.

GET /api/v1/webhooks

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"
    }
  ]
}
Brand profiles

A profile is one saved brand for the PDF: a company name, a contact block, two colours and a logo. Keep one per customer you resell reports to, and name it on a download link as ?brand=<profileId>. The report renders with that customer's brand, logo included, on an order you placed. Nothing about your own account defaults changes.

MethodPathDoes
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. Links that name it fall back to your defaults
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 profile's name and colours stay
FieldRequiredRules
nameTo createYour own label — usually the customer's name. Never printed. 80 chars
companyNameTo createPrints on the header band, footer and cover attribution. 80 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
curl -X POST "https://api.roofquery.com/api/v1/branding/profiles" \
  -H "Authorization: Bearer rq_live_sk_..." \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Acme Roofing — Louisville",
    "companyName": "Acme Roofing",
    "address": "900 Bardstown Rd, Louisville, KY 40204",
    "publicPhone": "5025559876",
    "publicEmail": "[email protected]",
    "brandColor": "#0F7A4D"
  }'
HTTP/1.1 201 Created

{
  "profile": {
    "profileId": "rq_brand_7f3a21c9d4e07b16",
    "name": "Acme Roofing — Louisville",
    "companyName": "Acme Roofing",
    "address": "900 Bardstown Rd, Louisville, KY 40204",
    "publicPhone": "5025559876",
    "publicEmail": "[email protected]",
    "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 any PDF link for that customer:

https://api.roofquery.com/api/v1/reports/orders/rq_ord_8f3a21c9/pdf?brand=rq_brand_7f3a21c9d4e07b16

A profile is complete. When a link names one, 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 customer's name. Explicit query params on the same link still win over the profile, field by field.

Ownership. The PDF route takes no key, so a profile is checked against the company that placed the order. A profile from another account, or an id that doesn't exist, returns 404 not_found — the same answer as an unknown order id, so the route can't be used to discover which ids exist. 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.

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.

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.

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.

FieldRequiredRules
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) or commercial
reportNameNoYour own label, printed on the report
claimNoClaim 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
notesNoFree text for the technician
clientInfoNoObject. 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.

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.

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.

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.

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": []
}
FieldWhat it is
terminalNothing further is expected on this order
filesReadyThe 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
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.

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

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.

QueryEffectMax
?download=1Forces a save dialog. Omit it and the PDF is served inline, so you can iframe it
?brand=A brand profile id. The report renders in that customer's brand, logo included
?companyName=Name on the header band, footer and cover attribution80
?address=First line of the contact block, under the name160
?publicPhone=Contact block. A 10-digit number is formatted (502) 555-0100; anything else prints as sent32
?publicEmail=Contact block160
?brandColor=Hex — URL-encode the # as %23
?brandTextColor=Text on the header band

Three layers, most specific wins. Your account defaults are set in the portal under Branding. A ?brand= profile replaces them wholesale for that download. The text and colour params below override either, field by field, for that one file. Anything you don't pass falls through to the next layer down.

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. To put a customer's logo on a download, upload it once to a brand profile and name the profile with ?brand=.

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

A profile is the durable way to brand a customer's reports; the query params are for a one-off. Both exist so you never touch your account settings to brand someone else's download. See White labeling.

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.

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.

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.

Free. It is another way to download a report you have already paid for, not a second product, and it draws no balance.

The claim number

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.

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.

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.

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. 409 not_ready until the order completes; 502 download_failed if the download failed — safe to retry, nothing was charged.

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.

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

ParameterValues
:structureall, or a 1-based structures[].index1 is the largest building. The index matches the figures beside it, so a link and its numbers always describe the same roof
:typeoutline, area, pitch, lengths
?format=svg (default) or png
?size=Edge length in pixels. Default 1000, min 200, max 4000. Always square
?download=1Forces 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.

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.

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

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

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.

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

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.

Sharing a report

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.

RouteKey required
/orders/:id/pdfNo
/orders/:id/xmlNo
/orders/:id/esxNo
/orders/:id/diagrams and each drawingNo
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.
updatedEvent 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.
revisions_requestedNoA revision is open. The previously delivered files stay available.
revision_completedYesThe 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.

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

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.

FieldWhat it is
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 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.

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.

Three ways to supply it, for three different questions.

LayerSet whereAnswers
Account defaultsPortal, Branding → Defaults"What does a report look like when nobody says otherwise?" Your own brand, logo included.
Brand profile/branding/profiles, or Branding → Profiles"What does this customer's report look like?" One saved brand per customer, logo included, named on the link as ?brand=.
Per-download paramsQuery params on the PDF URL"Change this one file." Name, contact block and colours for a single download. No logo.

Most specific wins: params over profile over defaults. A profile is complete — when one is named, nothing falls through to your defaults — because a half-applied brand prints one company's name over another company's phone number.

# A customer's report, in their brand, with their logo
curl "https://api.roofquery.com/api/v1/reports/orders/rq_ord_8f3a21c9/pdf?brand=rq_brand_7f3a21c9d4e07b16" \
  -o acme-report.pdf

# The same, as a one-off without a profile (no logo this way)
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
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 customers is not going to click through two hundred forms, and the id has to end up on a customer record in your database. 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 a default or a profile and every report you've already ordered comes back re-skinned on its 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
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
BILLING_SETUP_REQUIRED402No card or balance on the account yet
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
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
REVISION_REJECTED409The revision was refused. The description says why
REVISION_FAILED502The revision couldn't be raised. Safe to retry
Files and diagrams
CodeHTTPMeaning
not_ready409Report hasn't completed, so the file doesn't exist yet. Carries the current status
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
validation400A required field is missing, a colour isn't hex, or text is over the header limit
not_found404No such profile on this account. Also what a PDF link with an unknown or foreign ?brand= returns
profile_limit409The account already has 500 profiles
empty_upload400The logo body was empty
too_large413The logo is over 5MB
unsupported_type415The logo isn't a PNG, JPEG, WebP, GIF, AVIF or SVG