Documentation

Roof measurement reports, by API

Send an address, get back measured roof geometry — as XML, as a white-labeled PDF, as an Xactimate .esx, or as roof diagrams. This is the complete reference, open to read without an account. You'll need one to get a key.

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

Branding is not on this list. Your defaults — company name, contact block, colours and logo — are set in the portal under Report branding. What the API does take is per-download branding: pass companyName, brandColor and brandTextColor as query params on the PDF URL to brand a single report for one of your own customers. See White labeling.

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

These params are the only way to set branding through the API. Your account defaults — company name, contact block, colours, logo — are configured in the portal under Report branding, and these override them for one download. Anything you don't pass falls through to the saved value.

Override the contact block as a unit. The header renders the company name with the address, phone and email stacked underneath it, so passing companyName alone puts a different company's name above your phone number — a report that tells the reader to call someone other than who it claims to be from. If you're rebranding a download, send all four.

A colour that isn't a hex string returns 400 validation rather than rendering something strange — #RGB through #RRGGBBAA are all accepted. Text over the length in the table is refused the same way rather than silently cut, because these render in a fixed-height header. There is no logo param: a logo is bytes, not something that fits in a URL, so every render uses the file on your account.

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

Returns the PDF bytes:

HTTP/1.1 200 OK
Content-Type: application/pdf
Content-Disposition: attachment; filename="RoofQuery_6515_Turnbridge_Pl_Prospect_KY_40059_USA_rq_ord_8f3a21c9.pdf"
Content-Length: 1370151
Cache-Control: no-store

Before the report is delivered:

HTTP/1.1 409 Conflict

{
  "error": "not_ready",
  "error_description": "Report has not completed yet — no PDF available.",
  "status": "in-progress"
}

The per-request overrides exist so you can brand a report for one of your customers without touching your account settings. There is no logo query parameter — a logo is bytes, not a link. See White labeling.

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. Set a company name and the header, footer, cover-page attribution and legal contact block all become yours; a small RoofQuery watermark remains in the footer. Leave it blank and the report falls back to full RoofQuery branding.

Two ways to supply it, and they answer different questions. Account-wide — your defaults, set once in the portal under Report branding. Every report uses them unless a download says otherwise. Per request — pass query params on the PDF URL to override the saved values for a single download, which is how you brand a report for one of your customers without touching your account settings.

There is no /branding endpoint. Defaults are settings, and settings are configured once by a person who can see the result — whether a colour reads against white, how a wordmark sits in the header, where the contact block wraps. A preview answers that; an endpoint doesn't, and a second place to set the same values is a second place for them to be wrong. The per-download params below are the part an API is genuinely better at, and they stay.

curl "https://api.roofquery.com/api/v1/reports/orders/rq_ord_8f3a21c9/pdf\
?companyName=Acme%20Roofing\
&address=900%20Bardstown%20Rd%2C%20Louisville%2C%20KY%2040204\
&publicPhone=5025559876\
&publicEmail=estimates%40acmeroofing.example\
&brandColor=%230F7A4D" \
  -o acme-report.pdf
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 your site moves — and it would mean this API fetching an arbitrary address on your say-so. Upload the bytes once and they're ours to serve.

The logo is set in the portal, under Report branding — there is no API endpoint for it. It's a once-per-account thing, and one you want to look at: how a wide wordmark sits in a square slot, where the transparent padding lands, whether a small icon survives being scaled up. A file picker with a preview answers that. Raw bytes posted at an endpoint don't. It is also the one piece of branding a per-download param can't override: a logo is bytes, not something you can put in a URL, so every render uses the file on the account.

Every logo is placed on a square canvas automatically. The report header gives the logo a 1:1 slot, so anything that isn't square would otherwise be letterboxed or centre-cropped at render time. Upload whatever shape you have — a wide wordmark, a tall badge, a favicon — and it's scaled to fit and padded with transparency to a 512×512 PNG. Nothing is ever cropped: your proportions are kept exactly, and the padding disappears into the header band. Small logos are scaled up to fill the canvas, so a 64px icon doesn't render as a speck.

Nothing is cached — the PDF is rendered at the moment you request it. Change your branding and every report you've already ordered comes back re-skinned on its next download. The XML file carries no branding at all.

Revisions

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