{
  "openapi": "3.1.0",
  "info": {
    "title": "Roofbird API",
    "version": "1.0.0",
    "description": "Read access to your scanned roofing leads.\n\n## For agents, read this first\n\n**Sort on `rank.percentile` ascending, not on `score.buy_probability`.**\n`percentile` 0 is the worst roof in the area, which is the best lead.\n`buy_probability` is an absolute per-house number the model assigns without\nseeing the other houses; it reads 54 on about 30% of all leads, so a list\nordered by it is close to arbitrary. `GET /v1/areas/{id}/leads` is already\nreturned in the correct order, so preserving the response order is enough.\n\n`rank.tier` is the field to quote to a human: `replace_now` and `worn` are\nworth a visit, `midlife` and `recent` are not. If `timing.why_now` says\n'No trigger — nurture', say so plainly rather than inventing urgency: a\ncontractor sent to five intact roofs stops trusting the whole list.\n\nContact details ARE returned for leads already unlocked: contact.phones\n(each with a dnc flag), contact.emails and contact.mailing_address are\npopulated when contact.unlocked is true, and reading them costs nothing.\nThey are null when locked. Unlocking spends a plan unlock and is\ndashboard-only, so no API call can spend that budget. Always pass on the\ndnc flag with a phone number.\n\nThis API is at full parity with the CSV export's 121 columns.\n\n## Rate limits, per API key, by plan\n\n| plan | per minute | per day |\n|---|---:|---:|\n| Free trial | 30 | 1,000 |\n| Hunter ($199/mo) | 120 | 10,000 |\n| Hunter Pro ($499/mo) | 300 | 50,000 |\n\nEvery response carries X-RateLimit-Limit-Minute, X-RateLimit-Remaining-Minute,\nX-RateLimit-Limit-Day, X-RateLimit-Remaining-Day and X-RateLimit-Tier. A 429\nreturns error.code=rate_limited with the window that tripped and retry_after_sec.\n\n## What a call costs\n\nReading through this API runs NO AI and deducts nothing from the scan quota,\nthe unlock allowance or the balance — only the request allowance above.\n\nANY CALL THAT MAKES THE AI DO WORK IS DEDUCTED FROM THE CUSTOMER'S BALANCE.\nA scan assesses roofs with a vision model and each one comes off the monthly\nscan quota.\n\nExtracting a homeowner's phone or email is a separate paid action: it spends\none unlock from the plan allowance (10 trial / 50 Hunter / 150 Hunter Pro)\nand overage is billed per unlock. It only ever happens when the customer\nasks for it in the dashboard. NO endpoint in this API can trigger one, and a\nscan does not trace contacts either — so nothing you call here can spend\nthat budget. Contacts already unlocked are returned and cost nothing to read.\nIf an action will cost the customer, say so first."
  },
  "servers": [
    {
      "url": "https://dashboard.roofbird.ai/api"
    }
  ],
  "security": [
    {
      "bearerAuth": []
    }
  ],
  "components": {
    "securitySchemes": {
      "bearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "description": "An API key from the dashboard, sent as `Authorization: Bearer rb_live_…`."
      }
    },
    "schemas": {
      "Lead": {
        "type": "object",
        "properties": {
          "id": {
            "type": "string",
            "format": "uuid"
          },
          "address": {
            "type": "object",
            "properties": {
              "street": {
                "type": [
                  "string",
                  "null"
                ]
              },
              "city": {
                "type": [
                  "string",
                  "null"
                ]
              },
              "state": {
                "type": [
                  "string",
                  "null"
                ]
              },
              "postcode": {
                "type": [
                  "string",
                  "null"
                ]
              },
              "lat": {
                "type": "number"
              },
              "lon": {
                "type": "number"
              }
            }
          },
          "rank": {
            "type": "object",
            "description": "The comparative signal, and the one to sort on. percentile 0 = worst roof in the area, which is the BEST lead. null when the lead predates ranking or its ranking call was unconfident.",
            "properties": {
              "percentile": {
                "type": [
                  "integer",
                  "null"
                ],
                "minimum": 0,
                "maximum": 1000
              },
              "tier": {
                "type": [
                  "string",
                  "null"
                ],
                "enum": [
                  "replace_now",
                  "worn",
                  "midlife",
                  "recent",
                  null
                ]
              },
              "is_ranked": {
                "type": "boolean"
              }
            }
          },
          "roof": {
            "type": "object",
            "properties": {
              "condition_score": {
                "type": [
                  "integer",
                  "null"
                ],
                "minimum": 0,
                "maximum": 10
              },
              "material": {
                "type": [
                  "string",
                  "null"
                ],
                "enum": [
                  "asphalt",
                  "metal",
                  "tile",
                  "wood",
                  "other",
                  "unknown",
                  null
                ]
              },
              "estimated_age_years": {
                "type": [
                  "integer",
                  "null"
                ]
              },
              "active_damage": {
                "type": [
                  "boolean",
                  "null"
                ],
                "description": "Tarp, missing shingles, exposed decking or a sagging ridge. Rare and high value."
              },
              "verdict": {
                "type": [
                  "string",
                  "null"
                ]
              }
            }
          },
          "property": {
            "type": "object",
            "properties": {
              "year_built": {
                "type": [
                  "integer",
                  "null"
                ]
              },
              "home_value_usd": {
                "type": [
                  "number",
                  "null"
                ]
              },
              "owner_name": {
                "type": [
                  "string",
                  "null"
                ]
              },
              "owner_occupied": {
                "type": [
                  "boolean",
                  "null"
                ]
              }
            }
          },
          "timing": {
            "type": "object",
            "properties": {
              "why_now": {
                "type": [
                  "string",
                  "null"
                ],
                "description": "Why today rather than any other day. Often 'No trigger — nurture', which is an honest answer and not a defect."
              },
              "pitch_hook": {
                "type": [
                  "string",
                  "null"
                ]
              },
              "storm_score": {
                "type": [
                  "integer",
                  "null"
                ]
              }
            }
          },
          "score": {
            "type": "object",
            "description": "Absolute per-roof score. Clusters heavily: 54 on roughly 30% of all leads. Do not sort on it.",
            "properties": {
              "buy_probability": {
                "type": [
                  "integer",
                  "null"
                ]
              },
              "note": {
                "type": "string"
              }
            }
          },
          "contact": {
            "type": "object",
            "description": "Populated only when `unlocked` is true — the customer has already paid for this lead, so reading it costs nothing. Null when locked; unlocking is dashboard-only.",
            "properties": {
              "unlocked": {
                "type": "boolean"
              },
              "unlocked_at": {
                "type": [
                  "string",
                  "null"
                ],
                "format": "date-time"
              },
              "owner_name": {
                "type": [
                  "string",
                  "null"
                ]
              },
              "owner_occupied": {
                "type": [
                  "boolean",
                  "null"
                ]
              },
              "mailing_address": {
                "type": [
                  "string",
                  "null"
                ]
              },
              "phones": {
                "type": [
                  "array",
                  "null"
                ],
                "items": {
                  "type": "object",
                  "properties": {
                    "number": {
                      "type": "string"
                    },
                    "dnc": {
                      "type": "boolean",
                      "description": "Do-Not-Call. Always pass this on with the number."
                    }
                  }
                }
              },
              "emails": {
                "type": [
                  "array",
                  "null"
                ],
                "items": {
                  "type": "string"
                }
              },
              "note": {
                "type": "string"
              }
            }
          },
          "status": {
            "type": [
              "string",
              "null"
            ],
            "enum": [
              "won",
              "contacted",
              "visited",
              "quote_sent",
              "lost",
              "dismissed",
              null
            ]
          }
        }
      }
    }
  },
  "paths": {
    "/v1/me": {
      "get": {
        "summary": "Confirm the key works, and read both quotas",
        "description": "Call this first. Verifies auth, and returns the single merged credit allowance (one credit = one house: full read + owner contact), the houses-ranked coverage allowance (a standing footprint across active areas — 1,000 trial / 10,000 Hunter / 25,000 Pro — that costs no credits), the houses-ranked coverage allowance (separate, and not charged in credits) plus current rate-limit headroom — so an agent never has to discover a limit by hitting a 402, and knows the unlock count before it suggests extracting a phone number.",
        "responses": {
          "200": {
            "description": "Account, key scopes and totals"
          },
          "401": {
            "description": "Missing or invalid key"
          }
        }
      }
    },
    "/v1/areas": {
      "get": {
        "summary": "List scan areas, newest first",
        "responses": {
          "200": {
            "description": "Areas with lead counts"
          },
          "401": {
            "description": "Missing or invalid key"
          }
        }
      }
    },
    "/v1/areas/{id}/coverage": {
      "get": {
        "summary": "Every roof we read in an area, not just the leads",
        "description": "How much of a service area has actually been looked at, and how much of it is not yet in /leads. A first scan typically ranks hundreds of houses and turns a handful into leads; this is the rest. Runs no AI and deducts nothing — `read` scope is enough. There is no API way to unlock one of these houses: that spends one credit from the plan and is dashboard-only. `tier` is COMPARATIVE — it means this roof looks worse than the roofs either side of it, judged from above against ~20 neighbours in one image. It is not a condition report; across roofs carrying both reads the four tiers span only ~7 points of buy_probability. Never relay a tier as 'this roof needs replacing'.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "include_pins",
            "in": "query",
            "description": "Return the individual roofs, not just the counts. Off by default because the counts answer most questions and a polygon can hold thousands of pins.",
            "schema": {
              "type": "boolean",
              "default": false
            }
          },
          {
            "name": "limit",
            "in": "query",
            "description": "Max pins returned when include_pins=true. Counts are always over the whole polygon and are never truncated by this.",
            "schema": {
              "type": "integer",
              "default": 100,
              "minimum": 1,
              "maximum": 1000
            }
          },
          {
            "name": "tier",
            "in": "query",
            "description": "Comma separated, e.g. `replace_now,worn`.",
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Coverage counts, plus pins when include_pins=true"
          },
          "401": {
            "description": "Missing or invalid key"
          },
          "404": {
            "description": "No scan area with that id on this account"
          }
        }
      }
    },
    "/v1/areas/{id}/leads": {
      "get": {
        "summary": "Leads in an area, best lead first",
        "description": "Already ordered worst-roof-first. Preserve the order you receive. Ordering is: leads the customer has already worked sink to the bottom; then leads whose unlocked contact turned out unusable (every number on the DNC registry, the contact vendor naming someone other than the deed holder, or no contact found at all) sort below leads with a workable contact; then comparative rank; then buy_probability. So two leads with identical scores can order differently once one of them has been unlocked — that is deliberate, and it means a door nobody can legally call never sits at the top of the list.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          },
          {
            "name": "limit",
            "in": "query",
            "schema": {
              "type": "integer",
              "default": 50,
              "minimum": 1,
              "maximum": 200
            }
          },
          {
            "name": "tier",
            "in": "query",
            "description": "Comma separated. e.g. `replace_now,worn` for doors worth walking today.",
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "ranked_only",
            "in": "query",
            "description": "Exclude leads with no comparative rank.",
            "schema": {
              "type": "boolean"
            }
          },
          {
            "name": "offset",
            "in": "query",
            "description": "Paging. The response carries has_more, next_offset and next_url. An area holds up to 500 leads, so the first page is not necessarily all of them.",
            "schema": {
              "type": "integer",
              "default": 0
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Leads plus meta.order",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Lead"
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "description": "Missing or invalid key"
          },
          "404": {
            "description": "No such area on this account"
          },
          "429": {
            "description": "Rate limited. Body carries the window and retry_after_sec."
          }
        }
      }
    },
    "/v1/scans": {
      "post": {
        "summary": "Start a scan to get NEW leads (spends scan quota)",
        "description": "The only endpoint that invokes AI and the only one that costs money. Needs a `write`-scoped key. Send one of { postcode }, { lat, lon } or { polygon }. Takes 90-180 seconds; the scan.completed webhook is the reliable completion signal. Returns 402 if the credit allowance or area limit is used up, and 409 if a scan is already running for this account. Every house unlocked is deducted from the plan's monthly CREDIT allowance — one credit covers the full roof read and the owner's contact together; there is no separate contact allowance. Comparative ranking of the rest of the polygon is not metered and does not appear here — those roofs are on the coverage map in the dashboard, where the customer can open any of them individually.",
        "parameters": [
          {
            "name": "Idempotency-Key",
            "in": "header",
            "required": false,
            "schema": {
              "type": "string"
            },
            "description": "Send this. If the call times out and you retry with the same key you get the ORIGINAL response back and nothing is charged twice; replays carry Idempotent-Replay: true. Kept 24h. Without it, a retry after completion creates a second area and spends the quota again."
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "properties": {
                  "idempotency_key": {
                    "type": "string",
                    "description": "Alternative to the Idempotency-Key header."
                  },
                  "postcode": {
                    "type": "string",
                    "description": "5-digit US postcode. A 12 sq mi box is centred on it — a postcode's own bounding box is far larger than one scan and often overlaps neighbours, so check `postcodes_found` in the response."
                  },
                  "lat": {
                    "type": "number"
                  },
                  "lon": {
                    "type": "number"
                  },
                  "polygon": {
                    "type": "object",
                    "description": "GeoJSON Polygon, max 12 sq mi."
                  },
                  "name": {
                    "type": "string"
                  },
                  "target_leads": {
                    "type": "integer",
                    "description": "Capped at the plan's per-scan limit."
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Scan finished. Carries the leads INLINE (already ranked), leads_kept, roofs_assessed, postcodes_found, and a `cost` block with remaining quota. Lead count is capped by the plan's per-scan limit: 3 on trial (8 on the very first scan), 10 on Hunter, 20 on Hunter Pro.",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "type": "object",
                      "properties": {
                        "scan_area_id": {
                          "type": "string",
                          "format": "uuid"
                        },
                        "leads_kept": {
                          "type": "integer"
                        },
                        "roofs_assessed": {
                          "type": "integer"
                        },
                        "candidates_in_polygon": {
                          "type": "integer"
                        },
                        "worth_knocking": {
                          "type": [
                            "integer",
                            "null"
                          ]
                        },
                        "postcodes_found": {
                          "type": "object",
                          "additionalProperties": {
                            "type": "integer"
                          }
                        },
                        "leads": {
                          "type": "array",
                          "items": {
                            "$ref": "#/components/schemas/Lead"
                          }
                        }
                      }
                    },
                    "cost": {
                      "type": "object",
                      "properties": {
                        "roofs_assessed": {
                          "type": "integer"
                        },
                        "scan_quota_remaining": {
                          "type": "integer"
                        },
                        "scan_quota_limit": {
                          "type": "integer"
                        },
                        "window": {
                          "type": "string",
                          "enum": [
                            "lifetime",
                            "month"
                          ]
                        }
                      }
                    }
                  }
                }
              }
            }
          },
          "402": {
            "description": "Scan quota or area limit exhausted."
          },
          "403": {
            "description": "Key is read-only; this needs the write scope."
          },
          "409": {
            "description": "A scan is already running on this account."
          },
          "429": {
            "description": "Rate limited."
          }
        }
      }
    },
    "/v1/leads/{id}/status": {
      "post": {
        "summary": "Record what happened with a lead (free, needs write scope)",
        "description": "Sets contacted / visited / quote_sent / won / lost / dismissed. Costs nothing: no AI runs and no quota moves. Do this after acting on a lead — worked leads sort below fresh ones, so recording it is what stops the same door being recommended again. Emits lead.status_changed.",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "type": "object",
                "required": [
                  "status"
                ],
                "properties": {
                  "status": {
                    "type": "string",
                    "enum": [
                      "viewed",
                      "contacted",
                      "visited",
                      "quote_sent",
                      "won",
                      "lost",
                      "dismissed"
                    ]
                  },
                  "notes": {
                    "type": "string",
                    "maxLength": 1000
                  }
                }
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Recorded"
          },
          "400": {
            "description": "Unknown status"
          },
          "403": {
            "description": "Key is read-only"
          },
          "404": {
            "description": "No such lead on this account"
          }
        }
      }
    },
    "/v1/leads/{id}": {
      "get": {
        "summary": "One lead in full",
        "parameters": [
          {
            "name": "id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string",
              "format": "uuid"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "A lead",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "data": {
                      "$ref": "#/components/schemas/Lead"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "description": "Missing or invalid key"
          },
          "404": {
            "description": "No such lead on this account"
          }
        }
      }
    }
  },
  "x-spec-url": "https://www.roofbird.ai/openapi.json"
}