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://api.rooftap.app/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.
Tiers - one API, free & paid
Your key's tier decides which fields return. Free keys get the zero-cost layers: storm history, FEMA hazard risk, Census neighborhood, and areactivation flag (when you pass lead_date). Paid keys ($3.95/record, sliding to $1.95 at volume) get everything in free plusthe real-time roof + property record, roof area, facets & pitch, edge measurements, material takeoff, cost estimate, complexity, pool/garage flags, owner & property details, and solar. Premium fields are simply omitted on the free tier; upgrade and they appear with no code change (same key). Every measurementcarries a confidence score, and you're never billed for a low-confidence read.
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
| Field | Type | Description |
|---|---|---|
| address | string · required | Free-form US address. Examples: "123 Main St, Austin, TX 78701", "5701 W Loma Ln Glendale AZ". |
| lead_id | string | Your internal id for the lead. Echoed back in the response so you can correlate without holding state. |
| lead_date | string (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. |
| owner_match_name | string | Name on the lead form. We compare against deed-of-record and return property.owner_match. |
POST https://api.rooftap.app/v1/enrich
X-API-Key: rt_live_abc123...
Content-Type: application/json
{
"address": "5701 W Loma Ln, Glendale, AZ 85302",
"lead_id": "lead-9821",
"owner_match_name": "Sarah Johnson"
}Response shape
Free keys return storm, risk (FEMA), neighborhood (Census), and reactivation (when lead_date is sent). Paid keys add roof, property, solar, and data_quality. Numeric fields can be null when source data is missing, never 0 as a fallback. The field tables below marked paid are omitted on the free tier.
200 OK
{
"ok": true,
"lead_id": "lead-9821",
"billable": true,
"address": {
"input": "5701 W Loma Ln, Glendale, AZ 85302",
"formatted": "5701 W Loma Ln, Glendale, AZ 85302, USA",
"lat": 33.5601,
"lng": -112.1888,
"place_id": "ChIJ..."
},
"roof": {
"area_sqft": 4003,
"squares": 44,
"predominant_pitch": "4/12",
"complexity": "cut_up",
"num_facets": 9,
"linear_measurements": {
"drip_edge_ft": 457
}
},
"gutter": {
"linear_feet": 127,
"downspout_count_estimate": 4
},
"siding": {
"wall_sqft_estimate": 2540,
"perimeter_ft": 254,
"stories": 1
},
"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
},
"property": {
"year_built": 1972,
"estimated_roof_age_years": 18,
"owner_match": true,
"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
},
"storm": {
"hail_2024": "1.75in",
"hail_5yr_count": 4,
"wind_5yr_max": "72mph",
"last_event_date": "2024-04-14",
"claim_eligible_window": {
"event_date": "2024-04-14",
"expires": "2026-04-14",
"days_remaining": 0
}
},
"data_quality": {
"confidence": "high",
"imagery_quality": "HIGH",
"footprint_match": true
}
}Roof fields
| Field | Type | Description |
|---|---|---|
| roof.area_sqft | number | Total roof area, square feet, computed from Solar API rooftop polygon. |
| roof.squares | number | Roofing squares (area_sqft / 100). Headline number for roofers. |
| roof.predominant_pitch | string | Most-common pitch across roof facets, format "X/12". |
| roof.complexity | enum | One of `simple` | `moderate` | `cut_up`. Drives waste % + labor estimates. |
| roof.num_facets | number | Distinct roof planes detected. Higher = more complex. |
| roof.linear_measurements.drip_edge_ft | number | Building 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). |
Gutter fields
| Field | Type | Description |
|---|---|---|
| gutter.linear_feet | number | Total linear feet of gutter scope. Equals the roof's eave length (which is what gutters attach to). |
| gutter.downspout_count_estimate | number | Rule-of-thumb 1 downspout per 35 ft of gutter, with a floor of 2. Adjust against the actual property if you have it. |
Siding fields
| Field | Type | Description |
|---|---|---|
| siding.wall_sqft_estimate | number | Wall surface area in square feet. Building perimeter × (stories × ~10 ft per story). Coarse but in the right ballpark for quoting. |
| siding.perimeter_ft | number | Building perimeter (the drip-edge length of the roof). Use directly for fascia + trim measurements. |
| siding.stories | number | Above-ground story count from property records. Defaults to 1 when records are missing. |
Solar fields
| Field | Type | Description |
|---|---|---|
| solar.suitability | enum | `high` | `medium` | `low` | `unsuitable`. Single field for lead-aggregator routing. High = $80-150 solar lead; low/unsuitable = roof-only. |
| solar.max_panel_count | number | Maximum panels that fit on the roof per Google Solar API. Buyers use this for system sizing. |
| solar.kw_potential | number | Max system DC capacity in kilowatts. Derived from max_panel_count × panel_capacity_watts. |
| solar.annual_kwh_potential | number | Estimated annual generation in kWh at maximum system size. Drives payback + savings calcs. |
| solar.max_array_area_sqft | number | Usable rooftop area for panels in square feet. |
| solar.sunshine_hours_per_year | number | Annual sun-hours at this latitude/orientation. Lower = lower suitability. |
| solar.panel_capacity_watts | number | Assumed per-panel rating used in the math (Google Solar API default, typically 250-400W). |
Property fields
| Field | Type | Description |
|---|---|---|
| property.year_built | number | Year the structure was built, per county assessor records. |
| property.estimated_roof_age_years | number | Best-effort roof age. Derived from permit history when available; falls back to year_built. |
| property.owner_match | boolean | true when `owner_match_name` from the request matches the deed-of-record. Filters wholesalers + storm-chasers. |
| property.owner_occupied | boolean | true when the property's mailing address matches the property address (homestead heuristic). |
| property.lot_size_sqft | number | Parcel size in square feet, from county records. |
| property.stories | number | Number of above-ground stories. Drives labor + safety equipment cost. |
| property.bedrooms | number | Bedroom count, county-assessor data. |
| property.bathrooms | number | Bathroom count, county-assessor data. |
Storm fields
| Field | Type | Description |
|---|---|---|
| storm.hail_2024 | string | Largest hail event in calendar 2024 within 1 mile. Format "X.XXin". Empty string if none. |
| storm.hail_5yr_count | number | Hail events ≥1.0in in the last 5 years within 1 mile, per NCEI Storm Events. |
| storm.wind_5yr_max | string | Max measured wind in the last 5 years within 1 mile. Format "XXmph". |
| storm.last_event_date | string | Date of the most recent qualifying storm event. ISO format (YYYY-MM-DD). Drives the claim-eligible window. |
| storm.claim_eligible_window | object | Derived window when a homeowner can still file an insurance claim for damage from the last event. { event_date, expires (event_date + 24mo), days_remaining }. Storm-chaser roofers use this to time outreach. |
Contact compliance fields (premium add-on)
| Field | Type | Description |
|---|---|---|
| contact_compliance.tcpa_safe_to_call | boolean | null | Result of federal + state DNC scrub against the contact phone. Only relevant for outbound cold outreach. Inbound consumer-initiated forms are TCPA-exempt. |
| contact_compliance.dnc_listed | boolean | null | True if the phone is on the federal Do-Not-Call list. |
| contact_compliance.last_checked | string | ISO timestamp of the last scrub. Cached 24h to avoid repeat upstream charges. |
Quality + meta
| Field | Type | Description |
|---|---|---|
| data_quality.confidence | enum | `high` | `medium` | `low`. We only bill when confidence is `high` or `medium`. |
| data_quality.imagery_quality | enum | `HIGH` | `MEDIUM` | `LOW`. LOW indicates canopy occlusion or stale imagery. |
| data_quality.footprint_match | boolean | true when our roof polygon aligns with the parcel's primary structure footprint. |
| billable | boolean | true when this call counts toward your monthly usage. false on quality-rejects + 4xx errors. |
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://api.rooftap.app/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.
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.
| Field | Type | Description |
|---|---|---|
| addresses | array · required | Up 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. |
| monitor | boolean | Default true. When true, every address is enrolled in ongoing next-storm monitoring so you're alerted the next time a qualifying event lands. |
| string | Optional. We email this address a summary when the job finishes. | |
| webhook_url | string | Optional. https only. We POST a completion event here when the job finishes (see below). |
curl -X POST https://api.rooftap.app/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://api.rooftap.app/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:
| HTTP | Code | What to do |
|---|---|---|
| 401 | invalid_key | Header missing or key unrecognized. Same key as /v1/enrich. |
| 402 | upgrade_required | Free keys get 1,000 addresses free for the lifetime of the key. Past that you must upgrade to continue at $0.01/address. |
| 400 | chunk_too_large | More than 1,000 addresses in one request. Split into chunks of 1,000 and POST each. |
| 429 | rate_limited | Max 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 queued → processing → completed (or failed).
| Query param | Type | Description |
|---|---|---|
| include | string | summary returns just the summary block and omits results. |
| status | string | follow_up filters results to hot leads only (the worth-a-call-today addresses). |
| limit | number | Page size. Max 1,000, default 500. |
| offset | number | Result offset for paging. |
curl https://api.rooftap.app/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
| Field | Type | Description |
|---|---|---|
| address | string | The address as supplied in the request. |
| external_ref | string | null | Your reference, echoed back when you passed the object form `{ address, external_ref }`. `null` for bare-string entries. |
| status | enum | `done` once processed, or `error` if the address couldn't be checked. Use `worth_following_up` for the hot-lead flag, not this. |
| matched | boolean | true when the address geocoded and matched a parcel with NOAA storm history. |
| last_event | string | null | Date of the most recent qualifying storm event near this address. ISO format (YYYY-MM-DD), `null` if none. |
| max_hail_in | number | null | Largest hail diameter in inches across the screened history. `null` if no hail on record. |
| events_5yr | number | Count of qualifying storm events in the last 5 years within range of this address. |
| worth_following_up | boolean | The headline flag. true when a storm since the lead date makes this a worth-a-call-today reactivation candidate. |
| trigger | object | null | The storm that flagged the address: `{ date, type, hail_in, wind_mph }`. `null` when `worth_following_up` is false. |
| days_since_last_storm | number | null | Whole days since the most recent severe (qualifying) storm. `null` when there's no qualifying storm on record. |
| claim_window_days | number | The 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_window | boolean | Whether the most recent storm is still inside that claim window. |
| priority | number | null | Call-priority, 1 = call first through 5 = floor (lowest). `null` when there's no qualifying storm. Results are returned sorted by `priority` ascending (1 first). |
| monitored | boolean | true 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.| HTTP | Code | What to do |
|---|---|---|
| 400 | invalid_json | Body wasn't valid JSON. Check Content-Type + payload. |
| 400 | address_required | Missing address field. |
| 401 | invalid_key | Header missing or key revoked. Rotate via support. |
| 402 | billing_required | Subscription payment failed. Update card in your billing portal. |
| 422 | address_unresolvable | Geocoder couldn't place the address. Not billed. |
| 422 | no_solar_coverage | Address is outside Solar API coverage. Not billed. |
| 429 | rate_limited | Honor Retry-After header. Default cap 10 rps per key. |
| 500 | internal_error | Our fault. Retry with exponential backoff. Not billed. |
Rate limits
- /v1/enrich - 10 rps per API key. 429 returns a
Retry-Afterheader 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 calls in a month and every call that period prices at $2.45, including the first 5,000.
| Volume | Per call | Notes |
|---|---|---|
| 0 – 5,000 / mo | $3.95 | Entry tier, no minimum. |
| 5,001 – 15,000 / mo | $3.25 | Auto-applied. |
| 15,001 – 30,000 / mo | $2.45 | Auto-applied. |
| 30,000+ / mo | $1.95 | Email support for >100k contracts. |
Quality guarantee
billable: falseand that call doesn't count toward your usage. No tickets, no clawbacks, no end-of-month reconciliation.Changelog
- 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_measurementsslimmed todrip_edge_ftonly 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-03 -
owner_match_namerequest parameter live. - 2026-02 - v1 GA. Public launch.
60-second self-serve key. Card on file via Stripe.
No NDA. No procurement loop. Cancel any time.