API · v1

RoofTap Enrichment API

One JSON call. Roof + property + storm data on every address. Same shape every CRM. Pay-as-you-go, never billed for bad reads.

Quickstart

Activate a key in 60 seconds at /integrations/signup. You'll see the key once on the success page, copy it before closing the tab. Every key is prefixed with rt_live_.

curl -X POST https://www.rooftap.app/api/v1/enrich \
  -H "X-API-Key: rt_live_abc123..." \
  -H "Content-Type: application/json" \
  -d '{ "address": "5701 W Loma Ln, Glendale, AZ 85302" }'

Authentication

Every request must include the X-API-Key header. Keys are tied to one billing account. Treat them like passwords: do not embed in client-side JavaScript or commit to git.

Lost your key? Email support@rooftap.appfrom the address on file and we'll rotate it.

Three products, one endpoint

The tier body param selects the product. What's free and what bills is never ambiguous:

tierWhat you getPrice
lite (default)Satellite-measured roof area, predominant pitch, squares, confidence flag. Main structure only — see Multiple structures.Free · unlimited — no card required. A bare call can never surprise-bill.
fullThe complete record: everything in lite plus owner & property details, storm history + claim window, AI roof condition, material takeoff + cost estimate, solar, gutter, risk & neighborhood, and the shareable report link. area_sqft is still the main structure; additional structures are added via verify_url.$3.95/record (sliding to $1.95 at volume). First 3 full records free on every key (trial_calls_remaining in the payload); keys without billing get a 402 after that.
premiumVendor-verified, ordering-grade measurements — full per-edge lengths (eaves / rakes / ridges / hips / valleys), guaranteed.$13.95 flatcoming soon (currently returns 501 premium_not_yet_available).
// tier omitted -> LITE. Free, unlimited, on any key.
POST /v1/enrich   { "address": "5701 W Loma Ln, Glendale, AZ 85302" }

200 OK
{
  "ok": true,
  "billable": false,
  "tier": "lite",
  "roof": {
    "area_sqft": 4003,
    "predominant_pitch": "4/12",
    "squares": 44
  },
  "formatted_address": "5701 W Loma Ln, Glendale, AZ 85302, USA",
  "data_quality": { "confidence": "high" }
}

How to read our accuracy labels. Roof area and pitch are satellite measurements, benchmark-validated, and carry the data_quality.confidence score (high / medium / low). Derived numbers (cost band, takeoff quantities, accessories, roof-age) are labeled estimates in their field docs, and records data (owner, year built, sale history) is sourced from our property-records provider. You are billed only on high-confidence reads. medium and low both return 422 and are never billed— we measured the property but won't charge for a number we can't stand behind (imagery quality, geocode precision, or a footprint cross-check fell short). Benchmarked against 41 HOVER reports through this endpoint: high lands at 4.7% median error, 87% within ±10%, while medium runs 17.6% median with 1-in-5 orderable. tier:"lite" still returns those measurements free, so you keep the estimate without paying for it.

Multiple structures

area_sqft is always the main structure. Not the whole property. A detached garage, shop, or casita is not included in that number, on either tier.

This is deliberate. Deciding which nearby buildings belong to a property cannot be done reliably from satellite data — a large outbuilding on this lot and a smaller house next door look identical on both size and distance. Rather than guess and silently over- or under-measure, we measure the main structure and tell you when we can see others.

Every response carries data_quality.structure_scope, which is currently always "primary_only". When other structures are detected nearby:

FieldTierMeaning
data_quality.additional_structures_possiblelitetrue when at least one other structure looks like it may belong to this property. Boolean only — lite stays thin.
data_quality.nearby_structures_detectedfullCount of structures that may belong to this property and are not in area_sqft.
data_quality.nearby_structures_notefullHuman-readable summary — count, distance to nearest, and total footprint — suitable for showing a contractor verbatim.
verify_urlfullWhere a contractor adds the missing structures. Opens the report with draw tools; measurements added there update the record.
// tier:"full" on a property with a detached building

{
  "roof": { "area_sqft": 2988, "predominant_pitch": "4/12", "squares": 34 },
  "verify_url": "https://www.rooftap.app/r/9f2c...",
  "data_quality": {
    "confidence": "high",
    "structure_scope": "primary_only",
    "nearby_structures_detected": 1,
    "nearby_structures_note": "1 additional structure detected nearby (nearest 13m,
       1,589 sqft of footprint) that may belong to this property. This measurement
       covers the MAIN STRUCTURE ONLY — review the aerial and add any additional
       structures before ordering material."
  }
}

// tier omitted -> lite, same property
{
  "roof": { "area_sqft": 2988, "predominant_pitch": "4/12", "squares": 34 },
  "report_link": "https://www.rooftap.app/r/9f2c...",
  "data_quality": {
    "confidence": "high",
    "structure_scope": "primary_only",
    "additional_structures_possible": true
  }
}
If you resell or display these numbers: pass verify_urlthrough to whoever is quoting the job, and surface the flag when it's set. A roofer who orders material from a main-structure number on a property with a detached garage will be short. The flag plus the link is how they catch it before ordering — we can tell them a structure is there, but only a human can confirm it belongs to the job.

Storm data

Every enrich response carries a storms block. By default it's just the last event storms.last_storm (date, type, hail size, wind, distance) plus the derived storms.claim_eligible_window. That's the answer to "when did the last storm hit this address, how big, and is the claim window still open" in one cheap call. The window we search is 2 years, which matches the ~24-month insurance claim window — older events aren't actionable.

Need the full history? Pass include: ["storm_history"] and the block expands to the complete 2-year event list (events[], newest first) plus max_hail_inches, max_wind_mph, lookback_years, and radius_miles.

// DEFAULT — last event only
POST /v1/enrich
{ "address": "5701 W Loma Ln, Glendale, AZ 85302" }

"storms": {
  "last_storm": {
    "date": "2025-09-18", "type": "hail",
    "hail_in": 1.75, "wind_mph": null, "distance_miles": 2.5
  },
  "claim_eligible_window": {
    "event_date": "2025-09-18", "expires": "2027-09-18", "days_remaining": 463
  }
}

// OPT IN — full 2-year history
POST /v1/enrich
{ "address": "5701 W Loma Ln, Glendale, AZ 85302",
  "include": ["storm_history"] }

"storms": {
  "last_storm":            { ... },
  "claim_eligible_window": { ... },
  "events": [
    { "noaa_event_id": "...", "event_type": "Hail", "event_date": "2025-09-18",
      "hail_size_inches": 1.75, "wind_speed_mph": null, "city": "Glendale",
      "state_abbr": "AZ", "distance_miles": 2.5, "damage_description": null }
  ],
  "max_hail_inches": 1.75, "max_wind_mph": 72,
  "lookback_years": 2, "radius_miles": 10
}
Storm data is part of the full record (2026-07 change). The old storm-only-free carve-out is retired: storm history ships on tier: "full" calls (lite carries no storm data), and a full call that narrows to "include": ["storms"] bills like any other. Your key's 3 free full records cover integration testing. For screening a list of addresses against storm history, use Bulk reactivation — that's priced per-address for exactly that job.

POST /v1/enrich

The primary endpoint. Returns the full enrichment payload synchronously. Cold call latency (first time we see an address) is typically 4-7 seconds, Solar API + property + storm history fetched in parallel. Cached addresses (same parcel called twice within the 7-day cache TTL) return sub-second. If you need to keep enrichment off your hot path, use the prewarm endpoint.

Request

FieldTypeDescription
addressstring · requiredFree-form US address. Examples: "123 Main St, Austin, TX 78701", "5701 W Loma Ln Glendale AZ".
tier"lite" | "full" | "premium"Defaults to lite (free). Pass "full" for the complete $3.95 record; "premium" is coming soon. See Three products, one endpoint.
lead_idstringYour internal id for the lead. Echoed back in the response so you can correlate without holding state.
lead_datestring (YYYY-MM-DD)Date the lead was generated. When present, the response adds a reactivation flag, true if a qualifying storm hit since that date.
includestring[]Narrow which blocks come back. Defaults to ["roof", "property", "imagery", "storms", "solar"]. Pass a subset to skip work you don't need. Two storm-specific tokens: storms returns the storms block (just the last event by default), and storm_history expands it to the full 2-year event list — see Storm data. Narrowing include trims the response, not the price — every call (including ["storms"]) bills the per-record rate once your trial allotment is used.
POST https://www.rooftap.app/api/v1/enrich
X-API-Key: rt_live_abc123...
Content-Type: application/json

{
  "address": "5701 W Loma Ln, Glendale, AZ 85302",
  "lead_id": "lead-9821"
}

// No "tier" -> LITE. Free, unlimited, never billed.
// Use this for integration testing and bulk address validation.

To get the complete billable record, add tier: "full":

POST https://www.rooftap.app/api/v1/enrich
X-API-Key: rt_live_abc123...
Content-Type: application/json

{
  "address": "5701 W Loma Ln, Glendale, AZ 85302",
  "tier": "full",          // <-- BILLS $3.95 PER CALL
  "lead_id": "lead-9821"
}
Before you loop the full tier over a list: tier: "full" bills $3.95 per callonce your 3 trial records are used, and there is no further prompt — every call after that is a charge. If you're testing an integration, validating an address list, or running anything in bulk for the first time, leave tier out and use lite — free and unlimited. Your remaining trial records, month-to-date charges, and spend caps are all on your API key page.

Response shape

The field tables below document the full tier (tier: "full") — roof, property, solar, imagery, storms, risk (FEMA), neighborhood (Census), reactivation (when lead_date is sent), and data_quality. Numeric fields can be null when source data is missing, never 0 as a fallback.

200 OK

{
  "ok": true,
  "lead_id": "lead-9821",
  "billable": true,
  "roof": {
    "area_sqft":          4003,
    "predominant_pitch":  "4/12",
    "complexity":         "cut_up",
    "num_facets":         9,
    "linear_measurements": {
      "drip_edge_ft": 457
    },
    "material_takeoff": {
      "squares":            44,
      "shingle_bundles":    132,
      "underlayment_rolls": 5,
      "nails":              14080,
      "accessories_estimate": {
        "cost_usd_low":  1100,
        "cost_usd_high": 1850,
        "includes": ["ice & water shield", "starter strip", "ridge cap", "drip edge"],
        "note": "Estimated from roof geometry — verify quantities on-site before ordering."
      }
    },
    "material_assumption": {
      "material":     "Concrete tile",
      "basis_source": "regional_default_for_AZ"
    },
    "ai_condition": {
      "material":               "tile",
      "ai_est_roof_age_years":  18,
      "ai_est_condition":       "fair",
      "replace_within_3yr":     "medium",
      "ai_roof_comments":       "Uniform terracotta tile with mild fading...",
      "basis":                  "aerial_vision+year_built",
      "model":                  "gemini-2.5-flash"
    }
  },
  "gutter": {
    "linear_feet":             127,
    "downspout_count_estimate": 4
  },
  "estimated_bid_range_usd": {
    "primary": { "low": 18400, "high": 27600, "basis_material": "Concrete tile" }
  },
  "solar": {
    "suitability":             "high",
    "max_panel_count":          34,
    "kw_potential":             13.6,
    "annual_kwh_potential":     14820,
    "max_array_area_sqft":      612,
    "sunshine_hours_per_year":  1820,
    "panel_capacity_watts":     400
  },
  "imagery": {
    "lat":               33.5601,
    "lng":              -112.1888,
    "formatted_address": "5701 W Loma Ln, Glendale, AZ 85302, USA",
    "imagery_date":      "2025-03-14"
  },
  "property": {
    "owner_name":       "JOHNSON FAMILY TRUST",
    "year_built":       1972,
    "roof_age_estimate": { "years": 18, "basis": "estimated_cycle", "lifecycle_phase": "mid_life" },
    "owner_occupied":   true,
    "lot_size_sqft":    7800,
    "stories":          1,
    "bedrooms":         3,
    "bathrooms":        2,
    "last_sale_date":   "2024-09-12",
    "last_sale_price":  412000,
    "sold_within_12mo": true
  },
  "storms": {
    "last_storm": {
      "date":           "2025-09-18",
      "type":           "hail",
      "hail_in":        1.75,
      "wind_mph":        null,
      "distance_miles":  2.5
    },
    "claim_eligible_window": {
      "event_date":     "2025-09-18",
      "expires":        "2027-09-18",
      "days_remaining": 463
    }
  },
  "verify_url": "https://www.rooftap.app/r/a1b2c3d4e5f6a7b8",
  "data_quality": {
    "confidence":       "high",
    "imagery_quality":  "HIGH",
    "polygon_source":   "msbf",
    "refinement_recommended": false
  }
}

Roof fields

FieldTypeDescription
roof.area_sqftnumberTotal roof area, square feet. Satellite-measured (Google Solar) and reconciled against the building footprint polygon — our most accurate measurement (±6% median on our benchmark set).
roof.predominant_pitchstringMost-common pitch across roof facets, format "X/12". Satellite-measured; benchmark-validated.
roof.complexityenumOne of `simple` | `moderate` | `cut_up`. Heuristic tier from facet count + pitch — directional, not a measurement.
roof.num_facetsnumberDistinct roof planes detected by satellite. Treat as approximate (segmentation can split or merge planes); useful as a complexity signal, not an exact count.
roof.linear_measurements.drip_edge_ftnumberBuilding outline perimeter, eaves + rakes summed. Drives drip-edge material quantity. Auto-mode reports ship this field only; per-edge breakdowns (eaves/rakes/ridges/hips/valleys) are surfaced when the contractor manually labels edges on the verify page (HIGH-confidence override).
roof.material_takeoff.squaresnumberRoofing squares including recommended waste. Headline number for roofers; rides on area_sqft, so it inherits measurement-grade accuracy.
roof.material_takeoff.shingle_bundles / underlayment_rolls / nailsnumberArea-driven quantities (3 bundles/square, coverage-standard rolls, ~320 nails/square).
roof.material_takeoff.accessories_estimateobject | nullONE bundled dollar band for the edge-driven accessories (ice & water shield, starter strip, ridge cap, drip edge): { cost_usd_low, cost_usd_high, includes[], note }. Bundled deliberately — itemized per-edge quantities aren't satellite-measurable to ordering grade, so we ship a stable cost band and you verify quantities on the roof.
roof.material_assumptionobjectThe material the cost math assumed: { material, basis_source } — property-records roof_type when known, else the regional default.
roof.ai_conditionobject | nullAI visual assessment of the roof from aerial imagery + records: { material, ai_est_roof_age_years, ai_est_condition (new|good|fair|worn|poor), replace_within_3yr (low|medium|high), ai_roof_comments, basis, model }. This is an AI ESTIMATE (the basis field says exactly what went in) — not a measurement, not permit data. Strongest for material ID and coarse condition; omitted when the model can't produce a usable read.

Gutter fields

FieldTypeDescription
gutter.linear_feetnumberTotal linear feet of gutter scope. Equals the roof's eave length (which is what gutters attach to).
gutter.downspout_count_estimatenumberRule-of-thumb 1 downspout per 35 ft of gutter, with a floor of 2. Adjust against the actual property if you have it.

Solar fields

FieldTypeDescription
solar.suitabilityenum`high` | `medium` | `low` | `unsuitable`. Single field for lead-aggregator routing. High = $80-150 solar lead; low/unsuitable = roof-only.
solar.max_panel_countnumberMaximum panels that fit on the roof per Google Solar API. Buyers use this for system sizing.
solar.kw_potentialnumberMax system DC capacity in kilowatts. Derived from max_panel_count × panel_capacity_watts.
solar.annual_kwh_potentialnumberEstimated annual generation in kWh at maximum system size. Drives payback + savings calcs.
solar.max_array_area_sqftnumberUsable rooftop area for panels in square feet.
solar.sunshine_hours_per_yearnumberAnnual sun-hours at this latitude/orientation. Lower = lower suitability.
solar.panel_capacity_wattsnumberAssumed per-panel rating used in the math (Google Solar API default, typically 250-400W).

Property fields

FieldTypeDescription
property.owner_namestring | nullOwner of record, sourced from our property-records provider. We surface the recorded name rather than a match boolean — legal names, trusts, and LLCs make automated name-matching unreliable; eyeball it against your lead.
property.year_builtnumberYear the structure was built. Sourced from our property-records provider (Rentcast).
property.roof_age_estimateobject{ years, basis, year_built, lifecycle_phase }. An ESTIMATE derived from year_built and typical re-roof cycles — the basis field says which. Not permit data. For a visual read of the actual roof, see roof.ai_condition.
property.owner_occupiedboolean | nullOwner-occupancy flag as reported by the property-records provider.
property.lot_size_sqftnumberParcel size in square feet, from property records.
property.storiesnumberNumber of above-ground stories. Drives labor + safety equipment cost.
property.bedroomsnumberBedroom count, from property records.
property.bathroomsnumberBathroom count, from property records.

Storm fields

FieldTypeDescription
storms.last_stormobject | nullThe single most recent NOAA storm event within 10 miles, returned by default on every call: { date (YYYY-MM-DD), type (`hail` | `wind` | `tornado` | `hurricane` | `tropical storm` | `winter storm` | `ice storm`), hail_in, wind_mph, distance_miles }. `null` when there's no qualifying event in the 2-year window. `winter storm` (snow load, blizzards) and `ice storm` (freezing-rain accretion) events never carry hail_in/wind_mph — the damage mechanism is ice/snow load, not hail or wind. They come from confirmed NCEI records scoped to an NWS forecast zone, so distance_miles is measured to that zone's centroid rather than to a point observation.
storms.claim_eligible_windowobject | nullDerived window when a homeowner can still file an insurance claim for damage from the last event: { event_date, expires (event_date + 24mo), days_remaining }. `null` when there's no qualifying event. Storm-chaser roofers use this to time outreach.
storms.eventsarrayFull event list, newest first (max 50). Returned ONLY when you pass include=["storm_history"]. Each: { noaa_event_id, event_type, event_date, hail_size_inches, wind_speed_mph, city, state_abbr, distance_miles, damage_description }.
storms.max_hail_inchesnumber | nullLargest hail diameter across the 2-year window, in inches. Included with `storm_history`. `null` if no hail on record.
storms.max_wind_mphnumber | nullHighest wind gust across the 2-year window, in mph. Included with `storm_history`. `null` if none on record.
storms.lookback_yearsnumberYears of NOAA history searched. Currently 2. Included with `storm_history`.
storms.radius_milesnumberSearch radius around the subject property, in miles. Currently 10. Included with `storm_history`.

Quality + meta

FieldTypeDescription
verify_urlstring | nullShareable link to a hosted, presentable measurement report for this exact lookup — roof wireframe, measurements, solar, storm history and property details, with a one-click PDF download. Returned on every successful call (trial calls included); attach it to a CRM deal or job, or hand it to the homeowner. Link lives at `https://www.rooftap.app/r/<token>` and stays live ~30 days. `null` only on the rare best-effort issuance failure. See Measurement report below.
data_quality.imagery_qualityenum`HIGH` | `MEDIUM` | `LOW`. LOW indicates canopy occlusion or stale imagery.
data_quality.polygon_sourcestring | nullWhich building-footprint source anchored the measurement (`msbf` | `osm` | `manual_override`), or null when no polygon was available.
billablebooleantrue when this call counts toward your monthly usage. false on lite calls (always free), quality-rejects, 4xx errors, and trial calls (every key ships with 3 free full records; the response includes trial_calls_remaining while they last).
trial_calls_remainingnumberPresent on trial calls only — how many free calls are left on this key. Once it hits zero, calls bill normally.

Measurement report

Every billable call returns a verify_url — a shareable link to a hosted, presentable measurement report for that exact lookup. No need to render the JSON yourself: the page shows the roof wireframe, area / facets / pitch, edge measurements, solar potential, storm history and property details, with a one-click PDF download. It's the same report viewer homeowners see, served at https://www.rooftap.app/r/<token>.

"verify_url": "https://www.rooftap.app/r/a1b2c3d4e5f6a7b8"

Drop it straight onto a CRM deal or job (e.g. a JobTread job or a HubSpot deal property), or hand it to the homeowner. The link stays live for about 30 days — long enough to survive a typical lead-to-close cycle. On a medium/low-confidence read (data_quality.refinement_recommended: true) the page also lets the contractor refine the building selection; high-confidence reads get the link too, with nothing to refine. The field is null only on the rare best-effort issuance failure — re-call to get a fresh one.

POST /v1/enrich/prewarm

Optional. Hit this at lead intake to kick off the enrichment fetch in the background. Returns 202 in under 50ms, the next/v1/enrich call on the same address (the one that runs when your routing decision happens) lands a warm cache and returns sub-500ms.

Prewarm calls don't bill. Only the real call does.

POST https://www.rooftap.app/api/v1/enrich/prewarm
X-API-Key: rt_live_abc123...

{ "address": "5701 W Loma Ln, Glendale, AZ 85302",
  "lead_id":  "lead-9821" }

202 Accepted · <50ms
{ "ok": true, "lead_id": "lead-9821",
  "message": "Prewarm queued. Hit POST /v1/enrich on the same address in 2-6s for a cache hit." }

Bulk reactivation

Run an aged lead list back through NOAA storm history to find the homeowners worth a call today. This is storm-only screening(NOAA storm history + a worth-a-call-today flag + optional ongoing next-storm monitoring), not a full enrich. It's async + batched: you enqueue a job and poll for results (or wait for a webhook). Use it to wake a dormant CRM, not to measure a roof, when you need measurements use /v1/enrich.

Single-address checks are always free — call /v1/enrich one address at a time, unlimited. The price here is only for bulk batch processing: the first 1,000 addresses are free, then $0.01 per address, so a 50,000-address bulk run is $500, far below the $3.95 full-enrich rate, because reactivation only screens storm history.

POST /v1/reactivate

Enqueue an async batch of up to 1,000 addresses per request. Authenticate with the same X-API-Key header you use for /v1/enrich. Returns 202 immediately with a job_id and a status_url to poll. For a 50k+ list, split into chunks of 1,000 and POST each chunk, every chunk returns its own job_id.

FieldTypeDescription
addressesarray · requiredUp to 1,000 entries. Each entry is either a free-form address string, or an object { address, external_ref? } where external_ref is echoed back so you can correlate to your CRM.
monitorbooleanDefault true. When true, every address is enrolled in ongoing next-storm monitoring so you're alerted the next time a qualifying event lands.
emailstringOptional. We email this address a summary when the job finishes.
webhook_urlstringOptional. https only. We POST a completion event here when the job finishes (see below).
curl -X POST https://www.rooftap.app/api/v1/reactivate \
  -H "X-API-Key: rt_live_abc123..." \
  -H "Content-Type: application/json" \
  -d '{
    "addresses": [
      "5701 W Loma Ln, Glendale, AZ 85302",
      { "address": "1804 E Vista Ave, Phoenix, AZ 85020", "external_ref": "lead-9821" }
    ],
    "monitor": true,
    "email": "ops@yourcompany.com",
    "webhook_url": "https://yourcompany.com/hooks/rooftap"
  }'

202 Accepted
{
  "ok": true,
  "job_id": "rjb_7f3c9a2e",
  "queued": 2,
  "status": "queued",
  "status_url": "https://www.rooftap.app/api/v1/reactivate/rjb_7f3c9a2e",
  "message": "Batch queued. Poll status_url or wait for the completion webhook."
}

The 202 body carries ok, job_id, queued (count accepted this request), status ("queued"), status_url, and a message.

Errors specific to this endpoint:

HTTPCodeWhat to do
401invalid_keyHeader missing or key unrecognized. Same key as /v1/enrich.
402upgrade_requiredFree keys get 1,000 addresses free for the lifetime of the key. Past that you must upgrade to continue at $0.01/address.
400chunk_too_largeMore than 1,000 addresses in one request. Split into chunks of 1,000 and POST each.
429rate_limitedMax 60 batches per minute per key. Honor Retry-After.

GET /v1/reactivate/{job_id}

Poll job status and pull results. Authenticate with X-API-Key, you can only read your own jobs. status moves queuedprocessing completed (or failed).

Query paramTypeDescription
includestringsummary returns just the summary block and omits results.
statusstringfollow_up filters results to hot leads only (the worth-a-call-today addresses).
limitnumberPage size. Max 1,000, default 500.
offsetnumberResult offset for paging.
curl https://www.rooftap.app/api/v1/reactivate/rjb_7f3c9a2e?status=follow_up \
  -H "X-API-Key: rt_live_abc123..."

200 OK
{
  "ok": true,
  "job_id": "rjb_7f3c9a2e",
  "status": "completed",
  "summary": {
    "total":       2,
    "processed":   2,
    "follow_ups":  1,
    "monitored":   2,
    "errors":      0
  },
  "results": [
    {
      "address":               "5701 W Loma Ln, Glendale, AZ 85302",
      "external_ref":           null,
      "status":                "done",
      "matched":                true,
      "last_event":            "2026-05-26",
      "max_hail_in":            1.75,
      "events_5yr":             4,
      "worth_following_up":     true,
      "trigger":               { "date": "2026-05-26", "type": "hail", "hail_in": 1.75, "wind_mph": null },
      "days_since_last_storm":  8,
      "claim_window_days":      365,
      "within_claim_window":    true,
      "priority":               1,
      "monitored":              true
    }
  ],
  "paging": { "limit": 500, "offset": 0, "returned": 1 }
}

The response carries ok, job_id, status, a summary block (total, processed, follow_ups, monitored, errors), the results array, and paging (limit, offset, returned).

Reactivation result fields

FieldTypeDescription
addressstringThe address as supplied in the request.
external_refstring | nullYour reference, echoed back when you passed the object form `{ address, external_ref }`. `null` for bare-string entries.
statusenum`done` once processed, or `error` if the address couldn't be checked. Use `worth_following_up` for the hot-lead flag, not this.
matchedbooleantrue when the address geocoded and matched a parcel with NOAA storm history.
last_eventstring | nullDate of the most recent qualifying storm event near this address. ISO format (YYYY-MM-DD), `null` if none.
max_hail_innumber | nullLargest hail diameter in inches across the screened history. `null` if no hail on record.
events_5yrnumberCount of qualifying storm events in the last 5 years within range of this address.
worth_following_upbooleanThe headline flag. true when a storm since the lead date makes this a worth-a-call-today reactivation candidate.
triggerobject | nullThe storm that flagged the address: `{ date, type, hail_in, wind_mph }`. `null` when `worth_following_up` is false.
days_since_last_stormnumber | nullWhole days since the most recent severe (qualifying) storm. `null` when there's no qualifying storm on record.
claim_window_daysnumberThe address state's property-claim window in days, used to rank this lead. Defaults to 730 (2 years) when the state can't be determined.
within_claim_windowbooleanWhether the most recent storm is still inside that claim window.
prioritynumber | nullCall-priority, 1 = call first through 5 = floor (lowest). `null` when there's no qualifying storm. Results are returned sorted by `priority` ascending (1 first).
monitoredbooleantrue when this address was enrolled in ongoing next-storm monitoring (driven by the request `monitor` flag).

priority is a call-ordering heuristic, not legal or insurance advice. We rank fresher storms higher so your team works the list top-down. The scale:

  • 1 - severe storm 14 days ago or less.
  • 2 - 15 to 90 days ago.
  • 3 - 91 to 365 days ago.
  • 4 - 366 days up to the state's claim window (only reachable in 2-year states).
  • 5 - past the state's claim window, or older than 2 years. Still listed, just the lowest rank.

The claim window is per US state (roughly 1 to 2 years) and is a best-effort heuristic. When we can't determine the state we default to 2 years (claim_window_days: 730). Leads are never dropped: anything past the window simply floors to priority 5.

Completion webhook

Optional. If you set webhook_urlon the enqueue call, we POST a completion event there when the job finishes, so you don't have to poll.

POST https://yourcompany.com/hooks/rooftap
Content-Type: application/json

{
  "event":   "reactivation.completed",
  "job_id":  "rjb_7f3c9a2e",
  "summary": {
    "total":      50000,
    "processed":  50000,
    "errors":     0,
    "follow_ups": 3142,
    "monitored":  50000
  }
}

Errors

All errors return JSON with { ok: false, error, message }. The error field is a stable machine-readable code; message is human-readable and may change between releases.

401 Unauthorized · invalid_key
{ "ok": false, "error": "invalid_key",
  "message": "X-API-Key header missing or unrecognized." }

422 Unprocessable Entity · address_unresolvable
{ "ok": false, "error": "address_unresolvable",
  "message": "Could not geocode the supplied address.",
  "billable": false }

429 Too Many Requests · rate_limited
{ "ok": false, "error": "rate_limited",
  "message": "Rate limit: 10 rps. Retry after 600ms." }
// Honor the Retry-After response header.
HTTPCodeWhat to do
400invalid_jsonBody wasn't valid JSON. Check Content-Type + payload.
400address_requiredMissing address field.
401invalid_keyHeader missing or key revoked. Rotate via support.
402billing_requiredSubscription payment failed. Update card in your billing portal.
422address_unresolvableGeocoder couldn't place the address. Not billed.
422no_solar_coverageAddress is outside Solar API coverage. Not billed.
422medium_confidence_propertyNew. We measured it but won't charge — imagery quality, geocode precision, or a footprint cross-check fell short. Not billed. tier:"lite" returns the same measurement free. confidence_reasons explains which check failed.
422low_confidence_propertyHeavy canopy, stale imagery, or footprint mismatch. Not billed.
429lite_daily_cap_reachedNew. Free lite calls are capped per key per rolling 24h. Lite runs the full measurement pipeline, so it is free but not unlimited. Running legitimate volume? Email support and we'll raise it.
429monthly_call_cap_reachedNew. The key's monthly call cap is now enforced across every tier, including free lite. calls_used and call_cap are in the body.
402spend_cap_reachedMonthly spend cap hit. Raise it on your account page, or use tier:"lite", which is always free.
429rate_limitedHonor Retry-After header. Default cap 10 rps per key.
500internal_errorOur fault. Retry with exponential backoff. Not billed.

Rate limits

  • /v1/enrich - 10 rps per API key. 429 returns a Retry-After header in seconds.
  • /v1/enrich/prewarm - 30 rps per API key (separate bucket).
  • Need higher caps for an aggregator burst? Email support, we lift to 50+ rps once we see your traffic profile.

Billing

Volume tiers are auto-applied for the entire month based on your final volume. Run 16,000 billable calls in a month and every one that period prices at $2.45, including the first 5,000. Trial-allotment calls and quality-rejected reads (billable: false) don't count toward your volume or your bill.

Each billable call is charged independently, including repeat lookups of the same address. Results are licensed for the end customer whose lookup triggered the call: don't store and re-serve our data to other third parties in place of a fresh call. One end-customer lookup, one call.

VolumePer callNotes
0 – 5,000 / mo$3.95Entry tier, no minimum.
5,001 – 15,000 / mo$3.25Auto-applied.
15,001 – 30,000 / mo$2.45Auto-applied.
30,000+ / mo$1.95Email support for >100k contracts.

Quality guarantee

We never bill for bad data.If we can't place the address, can't resolve a roof polygon with confidence, or the underlying imagery is canopy-occluded, the response includes billable: falseand that call doesn't count toward your usage. No tickets, no clawbacks, no end-of-month reconciliation.

Changelog

  • 2026-07-30 - Billing now requires high confidence. medium joins low in returning 422 unbilled (medium_confidence_property) — benchmarking against 41 HOVER reports put medium at 17.6% median error vs 4.7% for high, and we won't charge for a number we can't stand behind. Lite still returns those measurements free. Also new: multiple-structure disclosure (structure_scope, nearby_structures_detected, additional_structures_possible) and per-key daily/monthly call caps (lite_daily_cap_reached, monthly_call_cap_reached).
  • 2026-07 - Lite / Full / Premium. New tier body param. Breaking: a call without tier now defaults to LITE — free, unlimited, returns roof.area_sqft + predominant_pitch + squares only, billable: false. Pass tier: "full" to keep receiving (and being billed for) the complete record. Full-tier trial allotment is 3 free records per key. tier: "premium" (vendor-verified per-edge measurements, $13.95 flat) is announced and returns 501 until live.
  • 2026-07 - Accuracy-tiered response. Added: roof.ai_condition (AI material / est. age / condition / replace-within-3yr + comments), property.owner_name (owner of record), roof.material_takeoff.accessories_estimate (one bundled dollar band for ice & water / starter / ridge cap / drip edge, replacing the itemized edge-driven lines), trial_calls_remaining. Removed: siding (moving to its own endpoint), contact_compliance (was a private-beta stub), property.footprint_area_sqft, and the owner_match_name request parameter (never shipped; the recorded owner name is surfaced instead). Breaking: the free tier and the storm-only-free carve-out are retired — every call bills after the key's one-time trial allotment.
  • 2026-06 - Storm block reworked. Responses now return a single storms.last_storm event by default (plus claim_eligible_window); pass include: ["storm_history"] for the full event list. The history window is now 2 years (was 5), aligned to the claim window. Storm-only calls (any request with no premium layer, e.g. include: ["storms"]) are now free on every key — no measurement runs and they're never billed, even on a paid key. Breaking: the old storm.hail_2024 / hail_5yr_count / wind_5yr_max fields are replaced by the structured storms block.
  • 2026-06 - Bulk reactivation launched. Async batch storm-screening for aged lead lists: POST /v1/reactivate + GET /v1/reactivate/{job_id}, first 1,000 addresses free, then $0.01/address, with optional ongoing next-storm monitoring and a completion webhook.
  • 2026-05 - Storm Alerts webhook API launched in free beta. Register addresses, get signed POSTs when qualifying hail/wind events land within 10 mi. linear_measurements slimmed to drip_edge_ft only in auto-mode reports; per-edge fields ship when the report is refined via the on-screen labeling tool. Storm 5-year wind max added.
  • 2026-04 - Prewarm endpoint launched. Volume tier breakpoints widened.
  • 2026-02 - v1 GA. Public launch.
Ship today

60-second self-serve key. Card on file via Stripe.

No NDA. No procurement loop. Cancel any time.

Get an API key →