{
  "openapi": "3.1.0",
  "info": {
    "title": "biz collect API",
    "version": "1.0.0",
    "description": "Search for businesses in a geographic area and (optionally) scrape their websites for contact emails, social profiles, and outgoing links. All endpoints are LLM-friendly: pass structured JSON, get structured JSON back. Async by design: POST /api/v1/search returns immediately with a job_id; poll GET /api/v1/jobs/:id until status=completed. Recommended flow for LLM agents: set `wait: true` on POST /api/v1/search to collapse the create-then-poll loop into a single synchronous call that blocks until the job finishes and returns the full results inline (see the Synchronous mode notes on that endpoint). Use result_pages to control Google Places result batches; each page is one batch of up to 20 raw results per keyword before dedupe. Use language_code to control Google Places localized fields and translated review text; English (`en`) is the default.\n\n## Request encoding\nSend all request bodies as UTF-8 and set `Content-Type: application/json; charset=utf-8`. This matters for accented place names and keywords (`Zürich`, `Genève`, `Müller`): a body sent in a legacy encoding (e.g. Windows CP1252) corrupts those characters into Unicode replacement characters, which would otherwise geocode to the wrong place. Such input is rejected up front with `400 invalid_request` (no credits are spent). Most HTTP clients send UTF-8 by default; if you build the body by hand on Windows, encode it explicitly as UTF-8.\n\n## Agent discovery\nPlain-text, llms.txt-standard docs for AI agents are served at `/llms.txt` (a concise index) and `/llms-full.txt` (a complete, self-contained integration playbook with request/response examples, parameter and error-code tables, the exhaustive-mode cost math, email-confidence semantics, and curl/Python/TypeScript snippets). Both are public and unlimited, served from this same API host.\n\n## Rate limits\nEvery authenticated `/api/v1/*` request is rate-limited per API key using a sliding-window counter. The per-minute cap depends on your workspace plan:\n\n| Plan | Requests / minute / key |\n| --- | --- |\n| free | 30 |\n| pro | 120 |\n| business | 300 |\n\nEvery response carries `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset` (unix seconds). When you exceed the cap you get a `429 rate_limited` with a `Retry-After` header (seconds to wait). A synchronous `wait: true` search counts as ONE request - the server-side polling that blocks until the job finishes is internal and never re-charges the limit. The public `GET /openapi.json` is unlimited."
  },
  "servers": [
    {
      "url": "https://kindly-lyrebird-376.convex.site",
      "description": "Production"
    }
  ],
  "security": [
    {
      "bearerAuth": []
    }
  ],
  "components": {
    "securitySchemes": {
      "bearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "description": "Send your API key as `Authorization: Bearer biz_live_...`. Generate keys in the web dashboard at /developers/api-keys."
      }
    },
    "headers": {
      "X-RateLimit-Limit": {
        "description": "The per-minute request cap for this API key's plan.",
        "schema": {
          "type": "integer"
        }
      },
      "X-RateLimit-Remaining": {
        "description": "Approximate requests remaining in the current trailing-60s window.",
        "schema": {
          "type": "integer",
          "minimum": 0
        }
      },
      "X-RateLimit-Reset": {
        "description": "Unix time (seconds) when the current rate-limit window resets.",
        "schema": {
          "type": "integer"
        }
      },
      "Retry-After": {
        "description": "Seconds to wait before retrying. Present on 429 responses.",
        "schema": {
          "type": "integer",
          "minimum": 1
        }
      }
    },
    "responses": {
      "RateLimited": {
        "description": "Rate limit exceeded for this API key (`rate_limited`). Wait the number of seconds in `Retry-After` before retrying. See the Rate limits section for the per-plan caps.",
        "headers": {
          "Retry-After": {
            "$ref": "#/components/headers/Retry-After"
          },
          "X-RateLimit-Limit": {
            "$ref": "#/components/headers/X-RateLimit-Limit"
          },
          "X-RateLimit-Remaining": {
            "$ref": "#/components/headers/X-RateLimit-Remaining"
          },
          "X-RateLimit-Reset": {
            "$ref": "#/components/headers/X-RateLimit-Reset"
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/RateLimited"
            },
            "examples": {
              "rateLimited": {
                "value": {
                  "error": {
                    "code": "rate_limited",
                    "message": "Rate limit exceeded: 30 requests per minute for this API key. Retry after 12s."
                  }
                }
              }
            }
          }
        }
      }
    },
    "schemas": {
      "ErrorEnvelope": {
        "type": "object",
        "required": [
          "error"
        ],
        "properties": {
          "error": {
            "type": "object",
            "required": [
              "code",
              "message"
            ],
            "properties": {
              "code": {
                "type": "string",
                "examples": [
                  "invalid_request",
                  "not_found"
                ]
              },
              "message": {
                "type": "string"
              }
            }
          }
        }
      },
      "RateLimited": {
        "type": "object",
        "required": [
          "error"
        ],
        "description": "Body of a 429 response. The request was rejected because the API key exceeded its per-minute request cap (see the Rate limits section). Inspect the `Retry-After` header (seconds) before retrying.",
        "properties": {
          "error": {
            "type": "object",
            "required": [
              "code",
              "message"
            ],
            "properties": {
              "code": {
                "type": "string",
                "enum": [
                  "rate_limited"
                ]
              },
              "message": {
                "type": "string"
              }
            }
          }
        }
      },
      "SearchRequest": {
        "type": "object",
        "required": [
          "location",
          "keywords"
        ],
        "properties": {
          "location": {
            "type": "string",
            "description": "City, region, or any recognizable place name. Geocoded via OpenStreetMap Nominatim. Send the request body as UTF-8 (see the Request encoding note): accented place names like `Zürich` or `Genève` are fully supported, but a non-UTF-8 body corrupts them into replacement characters and is rejected with `400 invalid_request` before any credits are spent.",
            "examples": [
              "Winterthur",
              "Zürich, Switzerland"
            ]
          },
          "keywords": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "minItems": 1,
            "maxItems": 20,
            "description": "Business types or terms to search for. Each keyword is searched separately inside the resolved geographic area.",
            "examples": [
              [
                "Treuhand"
              ],
              [
                "bakery",
                "cafe"
              ]
            ]
          },
          "radius_km": {
            "type": "number",
            "minimum": 1,
            "maximum": 50,
            "default": 10,
            "description": "Search radius from the resolved location, in kilometers."
          },
          "result_pages": {
            "type": "integer",
            "minimum": 1,
            "maximum": 3,
            "default": 1,
            "description": "How many Google Places Text Search pages to fetch per keyword. Each page is one Google Places request and returns up to 20 raw places before dedupe."
          },
          "language_code": {
            "type": "string",
            "default": "en",
            "pattern": "^[a-z]{2,3}(-[A-Z]{2})?$",
            "examples": [
              "en",
              "it",
              "de",
              "pt-BR"
            ],
            "description": "Preferred Google Places response language for localized fields, review translations, review summaries, and opening-hour descriptions. Defaults to English (`en`) when omitted."
          },
          "scrape_emails": {
            "type": "boolean",
            "default": true,
            "description": "Whether to fetch each business's website and extract emails, social profiles, and outgoing links. Adds ~30-90s to job duration. Disable for fast list-only queries."
          },
          "scrape_mode": {
            "type": "string",
            "enum": [
              "respectful",
              "aggressive"
            ],
            "default": "respectful",
            "description": "How website scraping treats each site's robots.txt. `respectful` (the default) fetches `https://<site>/robots.txt` and honors it per RFC 9309 for the `BusinessDataCollector` agent: if a site disallows its homepage the whole site is skipped (counted in `robots_skipped_count`); if it disallows specific contact paths only those are skipped. A missing, unreachable, or unparseable robots.txt is treated as allow-all. `aggressive` ignores robots.txt entirely and scrapes every reachable page - use it only when you are responsible for, and entitled to, scrape the target sites. Only affects scraping when `scrape_emails` is true."
          },
          "render_fallback": {
            "type": "boolean",
            "default": true,
            "description": "Headless-rendering fallback for JavaScript-built sites (Wix, Squarespace, React SPAs). Default ON. When the plain-HTML scrape of a site finds no emails AND the page looks JS-rendered or near-empty, the homepage is re-fetched through a headless renderer (Browserless) and extraction re-runs - recovering contact data a static fetch misses. Each rendered site costs +1 credit, debited at job completion (so a failed job is never charged for renders). The charge bypasses the per-plan daily-spend pre-check (it is post-hoc) but still counts toward your daily spend tally; if your balance can't cover the full amount it is clamped to whatever is available (never negative). See `rendered_sites_count` and `render_credits_charged` on the job. Graceful degradation: if the rendering infrastructure is unavailable (no Browserless key configured server-side), searches still succeed with the plain-HTML scraper and NO render charges apply. Set `render_fallback: false` to guarantee zero render charges. Only affects scraping when `scrape_emails` is true."
          },
          "resolve_contacts": {
            "type": "boolean",
            "default": true,
            "description": "Whether to resolve named, salutation-ready contacts from each scraped website (spec phase 2). Defaults to `true` when `scrape_emails` is true, `false` otherwise. When on, the scraper turns the emails and the already-fetched page HTML (homepage + the prioritized contact pages) into a per-email `contact` (first/last name, title, gender, German salutation, localized honorifics, confidence, evidence URL) plus an `is_role_account` flag, and a business-level `people[]` roster of everyone the site named. Pure + heuristic: no LLM, no extra network calls, and NO extra credits - it reuses the pages the email scrape already fetched (bounded by the same contact-page cap). Only matters when `scrape_emails` is true."
          },
          "contact_pages": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "maxItems": 20,
            "default": [
              "impressum",
              "kontakt",
              "contact",
              "team",
              "ueber-uns",
              "about",
              "mitarbeiter",
              "people"
            ],
            "description": "Prioritized contact-page path slugs that contact resolution biases the website crawl toward (the pages a business publishes its named people on). The total number of extra pages fetched per site is still capped (so this only reorders discovery priority and never raises cost). Only used when `resolve_contacts` is true."
          },
          "gender_inference": {
            "type": "boolean",
            "default": true,
            "description": "Whether contact resolution consults a first-name -> gender dataset to fill `gender`/`salutation_de` when no explicit honorific (Herr/Frau/...) is printed. Gender is biased to `unknown` either way (a wrong honorific is worse than a neutral one). Set `false` to only ever assign gender from an explicit printed honorific. Only used when `resolve_contacts` is true."
          },
          "coverage": {
            "type": "string",
            "enum": [
              "standard",
              "exhaustive"
            ],
            "default": "standard",
            "description": "Search coverage mode. `standard` runs one Google Places Text Search per keyword over the whole radius (fast, but Google caps each query at 60 results). `exhaustive` subdivides the radius into a grid of smaller sub-circle tiles and queries each tile, deduping by place id, to escape the 60-result ceiling and approach full coverage of the area. Exhaustive multiplies cost by the tile count: credits_charged = tile_count x result_pages x unique keyword queries x 20. Tile count grows with radius (a 10km radius yields tens of tiles), capped at 64 tiles per keyword."
          },
          "max_credits": {
            "type": "integer",
            "minimum": 20,
            "description": "Optional spend cap for `exhaustive` searches. If the full grid's cost would exceed this, the tile list is truncated (keeping the most central tiles) so the charge fits under max_credits, and the job is flagged `coverage_truncated: true`. Must be at least 20 (the cost of one Google Places page for one keyword); if it cannot afford even a single tile the request is rejected with 400. Ignored for `standard` coverage."
          },
          "title": {
            "type": "string",
            "description": "Optional label for the job, shown in the dashboard. Auto-generated if omitted."
          },
          "wait": {
            "type": "boolean",
            "default": false,
            "description": "Synchronous mode. When true, this single request blocks server-side until the job finishes (or `wait_timeout_seconds` elapses) and returns the completed results inline - no create-then-poll loop. On completion you get 200 with the SAME envelope as GET /api/v1/jobs/{job_id} (job fields + full `businesses` array + coverage fields) and `wait_timed_out: false`. If the job fails you still get 200 (status `failed`, `error` set; credits auto-refunded) - a job-level failure is not an HTTP error. If the timeout elapses first you get the normal 202 accepted body plus `wait_timed_out: true`; fall back to polling `poll_url`."
          },
          "wait_timeout_seconds": {
            "type": "integer",
            "minimum": 10,
            "maximum": 540,
            "default": 300,
            "description": "Max seconds to block in synchronous mode before returning the 202 timeout variant. Only valid with `wait: true` (sending it without `wait: true` is a 400). Capped at 540s to stay within the platform's 10-minute request limit."
          }
        }
      },
      "SearchAccepted": {
        "type": "object",
        "required": [
          "job_id",
          "status",
          "poll_url"
        ],
        "properties": {
          "job_id": {
            "type": "string"
          },
          "status": {
            "type": "string",
            "description": "`queued` on a fresh accepted job; an idempotent replay echoes the existing job's current status."
          },
          "poll_url": {
            "type": "string",
            "description": "Relative URL to poll for job status and results."
          },
          "scrape_emails": {
            "type": "boolean"
          },
          "scrape_mode": {
            "type": "string",
            "enum": [
              "respectful",
              "aggressive"
            ],
            "description": "Robots.txt handling the job will run with. `respectful` honors robots.txt; `aggressive` ignores it."
          },
          "render_fallback": {
            "type": "boolean",
            "description": "Whether the headless-render fallback is enabled for this job (+1 credit per rendered site, charged at completion). Default true."
          },
          "resolve_contacts": {
            "type": "boolean",
            "description": "Whether contact resolution runs for this job. Defaults to whether emails are scraped."
          },
          "contact_pages": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Prioritized contact-page slugs the resolution pass biases the crawl toward."
          },
          "gender_inference": {
            "type": "boolean",
            "description": "Whether contact resolution consults the names dataset for gender. Default true."
          },
          "location": {
            "type": "string"
          },
          "resolved_location": {
            "type": [
              "string",
              "null"
            ],
            "description": "The geocoder's full resolved place name for the supplied `location` (e.g. `Zürich, Switzerland`). Use it to verify the search landed where you intended before relying on results. Null if unavailable."
          },
          "geocode_confidence": {
            "type": [
              "number",
              "null"
            ],
            "description": "Geocoder match confidence (Nominatim importance, 0-1). Lower values warrant verifying `resolved_location`. Null if unavailable."
          },
          "geocode_country_code": {
            "type": [
              "string",
              "null"
            ],
            "description": "ISO 3166-1 alpha-2 country code of the resolved location (lowercase)."
          },
          "radius_km": {
            "type": "number"
          },
          "result_pages": {
            "type": "integer",
            "minimum": 1,
            "maximum": 3
          },
          "language_code": {
            "type": [
              "string",
              "null"
            ],
            "default": "en"
          },
          "keywords": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "coverage": {
            "type": "string",
            "enum": [
              "standard",
              "exhaustive"
            ],
            "description": "Coverage mode the job will run in."
          },
          "tile_count": {
            "type": "integer",
            "minimum": 1,
            "description": "Number of grid tiles charged and queried per keyword. Always 1 for `standard` coverage; for `exhaustive` it reflects the (possibly truncated) tile count this search was billed for."
          },
          "coverage_truncated": {
            "type": "boolean",
            "description": "True when the exhaustive tile list was truncated to fit `max_credits` (or the 64-tile hard cap), so coverage is partial. Always false for `standard` coverage."
          },
          "credits_charged": {
            "type": "integer",
            "minimum": 0,
            "description": "Credits charged for a newly accepted search: tile_count x result_pages x unique keyword queries x 20 (tile_count is 1 for standard coverage). Automatically refunded to your balance if the job ends in `failed`. Creation path ONLY - omitted on idempotent replay responses."
          },
          "credits_remaining": {
            "type": "integer",
            "minimum": 0,
            "description": "Account credit balance after a newly accepted search. Creation path ONLY - omitted on idempotent replay responses."
          },
          "idempotent_replay": {
            "type": "boolean",
            "description": "Replay paths ONLY. True when this response is a replay of an earlier identical request (same Idempotency-Key); absent on a freshly created job."
          },
          "wait_timed_out": {
            "type": "boolean",
            "description": "Present only on a synchronous (`wait: true`) request that hit its `wait_timeout_seconds` before the job finished. Always `true` here. The job is still running - fall back to polling `poll_url`."
          }
        }
      },
      "LocalizedText": {
        "type": [
          "object",
          "null"
        ],
        "properties": {
          "text": {
            "type": "string"
          },
          "language_code": {
            "type": [
              "string",
              "null"
            ]
          }
        },
        "description": "Localized text normalized from Google Places LocalizedText."
      },
      "GoogleDate": {
        "type": [
          "object",
          "null"
        ],
        "properties": {
          "year": {
            "type": [
              "integer",
              "null"
            ]
          },
          "month": {
            "type": [
              "integer",
              "null"
            ]
          },
          "day": {
            "type": [
              "integer",
              "null"
            ]
          }
        }
      },
      "OpeningHoursPoint": {
        "type": [
          "object",
          "null"
        ],
        "properties": {
          "date": {
            "$ref": "#/components/schemas/GoogleDate"
          },
          "truncated": {
            "type": [
              "boolean",
              "null"
            ]
          },
          "day": {
            "type": [
              "integer",
              "null"
            ],
            "minimum": 0,
            "maximum": 6,
            "description": "0 is Sunday, 1 is Monday, etc."
          },
          "hour": {
            "type": [
              "integer",
              "null"
            ],
            "minimum": 0,
            "maximum": 23
          },
          "minute": {
            "type": [
              "integer",
              "null"
            ],
            "minimum": 0,
            "maximum": 59
          }
        }
      },
      "OpeningHoursPeriod": {
        "type": "object",
        "properties": {
          "open": {
            "$ref": "#/components/schemas/OpeningHoursPoint"
          },
          "close": {
            "$ref": "#/components/schemas/OpeningHoursPoint"
          }
        }
      },
      "OpeningHours": {
        "type": [
          "object",
          "null"
        ],
        "properties": {
          "open_now": {
            "type": [
              "boolean",
              "null"
            ]
          },
          "weekday_descriptions": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Localized strings describing the opening hours."
          },
          "periods": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/OpeningHoursPeriod"
            }
          },
          "next_open_time": {
            "type": [
              "string",
              "null"
            ],
            "description": "RFC 3339 timestamp for the next opening time."
          },
          "next_close_time": {
            "type": [
              "string",
              "null"
            ],
            "description": "RFC 3339 timestamp for the next closing time."
          }
        }
      },
      "PlaceReview": {
        "type": "object",
        "properties": {
          "name": {
            "type": [
              "string",
              "null"
            ]
          },
          "relative_publish_time_description": {
            "type": [
              "string",
              "null"
            ]
          },
          "text": {
            "$ref": "#/components/schemas/LocalizedText"
          },
          "original_text": {
            "$ref": "#/components/schemas/LocalizedText"
          },
          "rating": {
            "type": [
              "number",
              "null"
            ],
            "minimum": 1,
            "maximum": 5
          },
          "author_name": {
            "type": [
              "string",
              "null"
            ]
          },
          "author_uri": {
            "type": [
              "string",
              "null"
            ]
          },
          "author_photo_uri": {
            "type": [
              "string",
              "null"
            ]
          },
          "publish_time": {
            "type": [
              "string",
              "null"
            ]
          },
          "flag_content_uri": {
            "type": [
              "string",
              "null"
            ]
          },
          "google_maps_uri": {
            "type": [
              "string",
              "null"
            ]
          },
          "visit_date": {
            "$ref": "#/components/schemas/GoogleDate"
          }
        },
        "description": "Normalized Google review. Google currently returns up to 5 reviews when requested."
      },
      "ReviewSummary": {
        "type": [
          "object",
          "null"
        ],
        "properties": {
          "text": {
            "$ref": "#/components/schemas/LocalizedText"
          },
          "disclosure_text": {
            "$ref": "#/components/schemas/LocalizedText"
          },
          "reviews_uri": {
            "type": [
              "string",
              "null"
            ]
          },
          "flag_content_uri": {
            "type": [
              "string",
              "null"
            ]
          }
        },
        "description": "AI-generated Google review summary, when available."
      },
      "SocialLink": {
        "type": "object",
        "required": [
          "platform",
          "url"
        ],
        "examples": [
          {
            "platform": "linkedin",
            "url": "https://www.linkedin.com/company/example"
          },
          {
            "platform": "instagram",
            "url": "https://instagram.com/example"
          }
        ],
        "properties": {
          "platform": {
            "type": "string",
            "examples": [
              "instagram",
              "facebook",
              "linkedin",
              "x",
              "tripadvisor"
            ],
            "description": "Normalized platform key inferred from the linked domain."
          },
          "url": {
            "type": "string",
            "format": "uri",
            "description": "Normalized absolute profile URL."
          }
        }
      },
      "Honorifics": {
        "type": [
          "object",
          "null"
        ],
        "description": "Localized honorific words for a resolved contact, present only when gender is known. Pick the field for your outreach language.",
        "properties": {
          "de": {
            "type": "string",
            "examples": [
              "Herr",
              "Frau"
            ]
          },
          "fr": {
            "type": "string",
            "examples": [
              "Monsieur",
              "Madame"
            ]
          },
          "it": {
            "type": "string",
            "examples": [
              "Signor",
              "Signora"
            ]
          },
          "en": {
            "type": "string",
            "examples": [
              "Mr",
              "Ms"
            ]
          }
        }
      },
      "Contact": {
        "type": [
          "object",
          "null"
        ],
        "description": "A resolved, salutation-ready outreach contact for a scraped email (the named person the address belongs to). Derived purely heuristically from the business's own page HTML (Impressum / team / about / a name printed next to the address); no LLM and no extra network calls. `null` when the address could not be tied to a person (role inboxes like info@ default to null unless a real name is printed right next to that exact address). Every field is backed by verbatim evidence on the page at `evidence_url`.",
        "required": [
          "first_name",
          "last_name",
          "full_name",
          "gender",
          "name_confidence",
          "name_source",
          "evidence_url"
        ],
        "properties": {
          "first_name": {
            "type": "string",
            "examples": [
              "Julian"
            ]
          },
          "last_name": {
            "type": "string",
            "examples": [
              "Schaffner"
            ]
          },
          "full_name": {
            "type": "string",
            "examples": [
              "Julian Schaffner"
            ]
          },
          "title": {
            "type": [
              "string",
              "null"
            ],
            "description": "Job title / role printed next to the name, if any.",
            "examples": [
              "Geschäftsführer",
              "Inhaber"
            ]
          },
          "gender": {
            "type": "string",
            "enum": [
              "m",
              "f",
              "unknown"
            ],
            "description": "Biased to `unknown`: only assigned from an explicit printed honorific (Herr/Frau/...) or, when `gender_inference` is on, a high-confidence first-name lookup. A wrong honorific in a cold email is worse than a neutral one."
          },
          "salutation_de": {
            "type": [
              "string",
              "null"
            ],
            "enum": [
              "Herr",
              "Frau",
              null
            ],
            "description": "German salutation word, or null when gender is unknown."
          },
          "honorifics": {
            "$ref": "#/components/schemas/Honorifics"
          },
          "name_confidence": {
            "type": "string",
            "enum": [
              "high",
              "medium",
              "low"
            ],
            "description": "How strongly the name is evidenced. `high`: an Impressum / structured-markup / team-page name, or the address printed right next to the name. `medium`: an initial+surname or surname-only local-part match, or a company-surname fallback. `low`: a first-name-only match."
          },
          "name_source": {
            "type": "string",
            "description": "Where the name came from: `email_next_to_name`, `structured_markup`, `impressum`, `team_page`, `local_part+company`, or `local_part_only`."
          },
          "evidence_url": {
            "type": "string",
            "format": "uri",
            "description": "URL of the page the name evidence was found on."
          }
        }
      },
      "Person": {
        "type": "object",
        "description": "A deduped named person the business published (Impressum / team / about), merging every email, source URL, and title seen for the same human across the site. Includes people who were never matched to a scraped email, so `people[]` is the full roster the pages named.",
        "required": [
          "first_name",
          "last_name",
          "full_name",
          "gender",
          "emails",
          "source_urls",
          "name_confidence",
          "name_source"
        ],
        "properties": {
          "first_name": {
            "type": "string"
          },
          "last_name": {
            "type": "string"
          },
          "full_name": {
            "type": "string"
          },
          "name_prefix": {
            "type": [
              "string",
              "null"
            ],
            "description": "Academic / honorary prefix that is part of the name (Dr., Prof., lic. iur.)."
          },
          "title": {
            "type": [
              "string",
              "null"
            ]
          },
          "gender": {
            "type": "string",
            "enum": [
              "m",
              "f",
              "unknown"
            ]
          },
          "salutation_de": {
            "type": [
              "string",
              "null"
            ],
            "enum": [
              "Herr",
              "Frau",
              null
            ]
          },
          "honorifics": {
            "$ref": "#/components/schemas/Honorifics"
          },
          "emails": {
            "type": "array",
            "items": {
              "type": "string",
              "format": "email"
            },
            "description": "Distinct emails associated with this person across the site (may be empty)."
          },
          "source_urls": {
            "type": "array",
            "items": {
              "type": "string",
              "format": "uri"
            },
            "description": "Distinct page URLs the person appeared on."
          },
          "name_confidence": {
            "type": "string",
            "enum": [
              "high",
              "medium",
              "low"
            ]
          },
          "name_source": {
            "type": "string"
          }
        }
      },
      "EmailDetail": {
        "type": "object",
        "required": [
          "email",
          "confidence",
          "has_mx",
          "is_role_account",
          "contact"
        ],
        "properties": {
          "email": {
            "type": "string",
            "format": "email",
            "description": "The verified address (lowercase, normalized). Matches an entry in `emails`."
          },
          "confidence": {
            "type": "string",
            "enum": [
              "high",
              "medium",
              "low",
              "unknown"
            ],
            "description": "Deliverability confidence based on syntax + MX (DNS-over-HTTPS), domain match, and address role. `high`: valid syntax, the domain has MX records, the local-part is a monitored inbox (info@, contact@, office@, mail@, hello@, kontakt@ all qualify), AND the address is on the business's own website domain OR a known free provider (gmail.com, etc.). `medium`: valid syntax with MX, but either a department/role address (sales@, support@, admin@, billing@, ...) OR off-domain relative to the business website. `low`: valid syntax but no MX (deliverable only via an A/AAAA implicit-MX record per RFC 5321), or a generic system/no-reply address (noreply@, no-reply@, donotreply@, postmaster@, abuse@, webmaster@). `unknown`: the MX lookup failed on BOTH DoH resolvers, so deliverability could not be determined (the address is NOT penalized for this). Note: this is domain-level + heuristic verification; we deliberately do NOT do an SMTP RCPT-TO mailbox probe, so `high` is not a guarantee the specific mailbox exists."
          },
          "has_mx": {
            "type": [
              "boolean",
              "null"
            ],
            "description": "true: the address domain has MX records. false: no MX, but an A/AAAA record exists (implicit-MX fallback). null: the DNS lookup was inconclusive (both DoH resolvers failed)."
          },
          "is_role_account": {
            "type": "boolean",
            "description": "True for a generic, non-personal inbox (info@, kontakt@, office@, sales@, no-reply@, ...). Such inboxes get `contact: null` unless a real person is printed right next to that exact address. Always false when contact resolution did not run (`resolve_contacts: false` or a legacy job)."
          },
          "contact": {
            "$ref": "#/components/schemas/Contact",
            "description": "The named person this address resolves to, or `null` when unresolved (role inboxes default to null). See the Contact schema. Always null when contact resolution did not run."
          }
        }
      },
      "Business": {
        "type": "object",
        "required": [
          "id",
          "name",
          "address",
          "emails",
          "social_links",
          "outgoing_links"
        ],
        "properties": {
          "id": {
            "type": "string"
          },
          "name": {
            "type": "string"
          },
          "address": {
            "type": "string"
          },
          "phone": {
            "type": [
              "string",
              "null"
            ]
          },
          "website": {
            "type": [
              "string",
              "null"
            ]
          },
          "website_is_directory": {
            "type": "boolean",
            "description": "True when `website` points at a business directory / aggregator listing (e.g. local.ch, search.ch, moneyhouse.ch) rather than the firm's own homepage. These are NOT scraped for emails (they would yield the directory's own contact data) - treat the URL as a listing, not the official site. The business's `phone` is still valid."
          },
          "emails": {
            "type": "array",
            "items": {
              "type": "string",
              "format": "email"
            },
            "description": "Ranked, deduped lowercase emails extracted from the business's website and its contact/Impressum pages, best contact first: on-domain mailboxes rank above free-provider addresses, which rank above off-domain ones; placeholder, vendor, and directory addresses are filtered out. Empty if scrape_emails=false, no website was available, or no emails were found. Note: the scraper fetches HTML and does not execute JavaScript, so sites that inject their address client-side may not yield an email."
          },
          "email_details": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/EmailDetail"
            },
            "description": "Per-email deliverability verification, aligned with `emails` (same order, best contact first). Each entry tags an address with a `confidence` tier and whether its domain has MX records (`has_mx`). Verification is syntax (RFC 5322 subset) plus an MX lookup over DNS-over-HTTPS (Google resolver, Cloudflare fallback), with an A/AAAA implicit-MX fallback per RFC 5321. It is free and always on. When `resolve_contacts` is on (default when scraping emails), each entry ALSO carries `is_role_account` and a resolved `contact` (the named person the address belongs to, or null). IMPORTANT: we do NOT perform an SMTP-handshake / RCPT-TO mailbox probe (not possible from serverless and harmful to sender reputation), so `confidence` reflects domain-level deliverability and address quality, never a guaranteed live mailbox. Empty when no emails were found."
          },
          "people": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Person"
            },
            "description": "The deduped roster of named people published on the business website (Impressum / team / about), from contact resolution. Includes people who were not matched to any scraped email - so a business with only an info@ address can still surface its Geschäftsführer here. Empty when `resolve_contacts` is false, scraping is off, or no people were named."
          },
          "social_links": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/SocialLink"
            },
            "examples": [
              [
                {
                  "platform": "linkedin",
                  "url": "https://www.linkedin.com/company/example"
                }
              ]
            ],
            "description": "Curated social/profile links extracted from the business website. Populated only when website enrichment runs. NOTE: an array of `{ platform, url }` OBJECTS - not an array of URL strings. Read each entry's `.url` (and `.platform`)."
          },
          "outgoing_links": {
            "type": "array",
            "items": {
              "type": "string",
              "format": "uri"
            },
            "description": "Deduped external HTTP(S) links found on the business website, including social links and useful third-party channels. Same-domain links, mailto, telephone, and JavaScript links are excluded."
          },
          "lat": {
            "type": [
              "number",
              "null"
            ]
          },
          "lng": {
            "type": [
              "number",
              "null"
            ]
          },
          "rating": {
            "type": [
              "number",
              "null"
            ],
            "minimum": 1,
            "maximum": 5,
            "description": "Google Places rating, when available."
          },
          "user_rating_count": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Number of Google user ratings."
          },
          "business_status": {
            "type": [
              "string",
              "null"
            ],
            "examples": [
              "OPERATIONAL",
              "CLOSED_TEMPORARILY",
              "CLOSED_PERMANENTLY"
            ],
            "description": "Business status from Google Places."
          },
          "primary_type": {
            "type": [
              "string",
              "null"
            ],
            "description": "Primary Google place type, such as `travel_agency`."
          },
          "types": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "All Google place types returned for the business."
          },
          "regular_opening_hours": {
            "$ref": "#/components/schemas/OpeningHours"
          },
          "reviews": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/PlaceReview"
            }
          },
          "review_summary": {
            "$ref": "#/components/schemas/ReviewSummary"
          }
        }
      },
      "Job": {
        "type": "object",
        "required": [
          "job_id",
          "status",
          "progress",
          "results_count"
        ],
        "properties": {
          "job_id": {
            "type": "string"
          },
          "title": {
            "type": "string"
          },
          "status": {
            "type": "string",
            "enum": [
              "queued",
              "running",
              "completed",
              "failed"
            ]
          },
          "progress": {
            "type": "integer",
            "minimum": 0,
            "maximum": 100
          },
          "results_count": {
            "type": "integer"
          },
          "emails_scraped_count": {
            "type": "integer"
          },
          "contacts_resolved_count": {
            "type": "integer",
            "minimum": 0,
            "description": "Number of scraped emails across the job that resolved to a named person (`email_details[].contact` is non-null). Mirrors `emails_scraped_count`. 0 when contact resolution did not run or matched nobody."
          },
          "robots_skipped_count": {
            "type": "integer",
            "minimum": 0,
            "description": "Number of sites skipped during scraping because their robots.txt disallowed our agent (respectful mode only). Always 0 in aggressive mode, when scrape_emails is false, or for legacy jobs created before this field existed."
          },
          "rendered_sites_count": {
            "type": "integer",
            "minimum": 0,
            "description": "Number of sites that escalated to the headless renderer (Browserless) because the plain-HTML scrape found nothing and the page looked JS-rendered. Each one is billed at +1 credit. 0 when render_fallback was disabled, rendering infra was unavailable, or no site needed it."
          },
          "render_credits_charged": {
            "type": "integer",
            "minimum": 0,
            "description": "Render credits actually charged at completion (rendered_sites_count x 1), clamped to the available balance so the pool never goes negative. Counts toward your daily spend tally but is exempt from the daily-spend pre-check (it is a post-hoc charge). A failed job is never charged for renders."
          },
          "scrape_emails": {
            "type": "boolean"
          },
          "scrape_mode": {
            "type": "string",
            "enum": [
              "respectful",
              "aggressive"
            ],
            "description": "Robots.txt handling for this job. Legacy jobs created before this field existed default to `respectful`."
          },
          "render_fallback": {
            "type": "boolean",
            "description": "Whether the headless-render fallback was enabled for this job. Legacy jobs created before this field existed default to `true`."
          },
          "resolve_contacts": {
            "type": "boolean",
            "description": "Whether contact resolution ran for this job. Legacy jobs created before this field existed default to whether emails were scraped."
          },
          "contact_pages": {
            "type": "array",
            "items": {
              "type": "string"
            },
            "description": "Prioritized contact-page slugs the resolution pass biased the crawl toward. Legacy jobs report the server default."
          },
          "gender_inference": {
            "type": "boolean",
            "description": "Whether contact resolution consulted the names dataset for gender. Legacy jobs default to `true`."
          },
          "location": {
            "type": "string"
          },
          "resolved_location": {
            "type": [
              "string",
              "null"
            ],
            "description": "Geocoder's full resolved place name for `location` (e.g. `Zürich, Switzerland`), so you can verify where the search ran. Null for legacy jobs created before this field existed."
          },
          "geocode_confidence": {
            "type": [
              "number",
              "null"
            ],
            "description": "Geocoder match confidence (Nominatim importance, 0-1). Null if unavailable."
          },
          "geocode_country_code": {
            "type": [
              "string",
              "null"
            ],
            "description": "ISO 3166-1 alpha-2 country code of the resolved location (lowercase)."
          },
          "lat": {
            "type": [
              "number",
              "null"
            ]
          },
          "lon": {
            "type": [
              "number",
              "null"
            ]
          },
          "radius_km": {
            "type": [
              "number",
              "null"
            ]
          },
          "result_pages": {
            "type": "integer",
            "minimum": 1,
            "maximum": 3,
            "description": "Google Places Text Search pages fetched per keyword. Each page is a batch of up to 20 raw results."
          },
          "language_code": {
            "type": [
              "string",
              "null"
            ],
            "default": "en",
            "description": "Requested Google Places response language for localized fields and translated review text. Legacy jobs created before this field existed may return null."
          },
          "keywords": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "coverage": {
            "type": "string",
            "enum": [
              "standard",
              "exhaustive"
            ],
            "description": "Coverage mode. Legacy jobs created before this field existed default to `standard`."
          },
          "tile_count": {
            "type": "integer",
            "minimum": 1,
            "description": "Grid tiles queried per keyword. 1 for standard coverage; the (possibly truncated) tile count for exhaustive coverage."
          },
          "coverage_truncated": {
            "type": "boolean",
            "description": "True when the exhaustive tile list was truncated to fit `max_credits` or the 64-tile hard cap."
          },
          "source": {
            "type": "string",
            "enum": [
              "webapp",
              "api"
            ]
          },
          "created_at": {
            "type": "integer",
            "description": "Epoch ms."
          },
          "started_at": {
            "type": [
              "integer",
              "null"
            ]
          },
          "completed_at": {
            "type": [
              "integer",
              "null"
            ]
          },
          "error": {
            "type": [
              "string",
              "null"
            ]
          },
          "businesses": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/Business"
            }
          },
          "wait_timed_out": {
            "type": "boolean",
            "description": "Present only on a synchronous (`wait: true`) POST /api/v1/search response. `false` on the 200 returned when the job finished in time (this envelope). Absent from plain GET /api/v1/jobs/{job_id} responses."
          }
        }
      },
      "UsageSummary": {
        "type": "object",
        "properties": {
          "requests": {
            "type": "integer"
          },
          "businesses_returned": {
            "type": "integer"
          },
          "emails_scraped": {
            "type": "integer"
          },
          "google_places_requests": {
            "type": "integer"
          },
          "period_start": {
            "type": "integer"
          },
          "period_end": {
            "type": "integer"
          }
        }
      },
      "WorkspaceSummary": {
        "type": [
          "object",
          "null"
        ],
        "required": [
          "id",
          "name",
          "slug"
        ],
        "properties": {
          "id": {
            "type": "string"
          },
          "name": {
            "type": "string"
          },
          "slug": {
            "type": "string"
          }
        },
        "description": "Workspace bound to the API key. Null only if the workspace row no longer exists."
      },
      "CreditAccount": {
        "type": "object",
        "required": [
          "balance",
          "plan_id",
          "daily_spend_limit",
          "spent_today",
          "spend_day_started_at"
        ],
        "properties": {
          "balance": {
            "type": "integer",
            "minimum": 0,
            "description": "Credit balance available to spend."
          },
          "plan_id": {
            "type": "string",
            "enum": [
              "free",
              "pro",
              "business"
            ],
            "description": "Current pricing plan that controls daily rate limits."
          },
          "daily_spend_limit": {
            "type": "integer",
            "minimum": 0,
            "description": "Daily plan credit limit."
          },
          "spent_today": {
            "type": "integer",
            "minimum": 0,
            "description": "Credits spent in the current UTC day window."
          },
          "spend_day_started_at": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Epoch ms timestamp for the current UTC spend window, or null before any spend has been recorded."
          }
        }
      },
      "Account": {
        "type": "object",
        "required": [
          "user_id",
          "workspace",
          "api_key",
          "credits",
          "usage_this_month"
        ],
        "properties": {
          "user_id": {
            "type": "string"
          },
          "workspace": {
            "$ref": "#/components/schemas/WorkspaceSummary"
          },
          "api_key": {
            "type": "object",
            "required": [
              "id",
              "name",
              "prefix"
            ],
            "properties": {
              "id": {
                "type": "string"
              },
              "name": {
                "type": "string"
              },
              "prefix": {
                "type": "string"
              }
            }
          },
          "credits": {
            "$ref": "#/components/schemas/CreditAccount"
          },
          "usage_this_month": {
            "$ref": "#/components/schemas/UsageSummary"
          }
        }
      },
      "WebhookEndpoint": {
        "type": "object",
        "required": [
          "id",
          "url",
          "events",
          "enabled"
        ],
        "properties": {
          "id": {
            "type": "string"
          },
          "url": {
            "type": "string",
            "format": "uri",
            "description": "https destination that receives the signed POST."
          },
          "events": {
            "type": "array",
            "items": {
              "type": "string",
              "enum": [
                "job.completed",
                "job.failed"
              ]
            },
            "description": "Event types this endpoint is subscribed to."
          },
          "description": {
            "type": [
              "string",
              "null"
            ]
          },
          "enabled": {
            "type": "boolean"
          },
          "created_at": {
            "type": "integer",
            "description": "Epoch ms."
          }
        }
      },
      "WebhookEndpointCreated": {
        "type": "object",
        "required": [
          "id",
          "url",
          "events",
          "secret"
        ],
        "properties": {
          "id": {
            "type": "string"
          },
          "url": {
            "type": "string",
            "format": "uri"
          },
          "events": {
            "type": "array",
            "items": {
              "type": "string"
            }
          },
          "description": {
            "type": [
              "string",
              "null"
            ]
          },
          "secret": {
            "type": "string",
            "description": "Signing secret in `whsec_<base64>` form. Returned ONCE on create -- store it now. Used to verify the `webhook-signature` header on every delivery (see the webhooks section).",
            "examples": [
              "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw"
            ]
          }
        }
      },
      "WebhookCreateRequest": {
        "type": "object",
        "required": [
          "url",
          "events"
        ],
        "properties": {
          "url": {
            "type": "string",
            "format": "uri",
            "description": "Must be a public https URL (internal, private, loopback, and link-local hosts are rejected). The signed event POST is delivered here.",
            "examples": [
              "https://example.com/hooks/biz-collect"
            ]
          },
          "events": {
            "type": "array",
            "items": {
              "type": "string",
              "enum": [
                "job.completed",
                "job.failed"
              ]
            },
            "minItems": 1,
            "description": "Event types to subscribe to.",
            "examples": [
              [
                "job.completed",
                "job.failed"
              ]
            ]
          },
          "description": {
            "type": "string",
            "description": "Optional human label, shown in the dashboard."
          }
        }
      },
      "WebhookJobEvent": {
        "type": "object",
        "required": [
          "type",
          "created_at",
          "data"
        ],
        "description": "Body of an outgoing job webhook. Sent as the POST body to your endpoint and signed (Standard Webhooks / Svix). The `data` block mirrors the `GET /api/v1/jobs/{job_id}` envelope.",
        "properties": {
          "type": {
            "type": "string",
            "enum": [
              "job.completed",
              "job.failed"
            ],
            "description": "The event that fired."
          },
          "created_at": {
            "type": "string",
            "format": "date-time",
            "description": "ISO 8601 timestamp when the event was generated."
          },
          "data": {
            "type": "object",
            "required": [
              "job_id",
              "status",
              "results_count"
            ],
            "properties": {
              "job_id": {
                "type": "string"
              },
              "status": {
                "type": "string",
                "enum": [
                  "completed",
                  "failed"
                ]
              },
              "results_count": {
                "type": "integer"
              },
              "emails_scraped_count": {
                "type": "integer"
              },
              "location": {
                "type": "string"
              },
              "keywords": {
                "type": "array",
                "items": {
                  "type": "string"
                }
              },
              "source": {
                "type": "string",
                "enum": [
                  "webapp",
                  "api"
                ]
              },
              "language_code": {
                "type": [
                  "string",
                  "null"
                ]
              },
              "created_at": {
                "type": "integer",
                "description": "Job creation epoch ms."
              },
              "completed_at": {
                "type": [
                  "integer",
                  "null"
                ]
              },
              "error": {
                "type": [
                  "string",
                  "null"
                ],
                "description": "Failure reason, present on job.failed."
              }
            }
          }
        }
      }
    }
  },
  "paths": {
    "/api/v1/search": {
      "post": {
        "summary": "Start a new business search",
        "description": "Creates an async job that searches for businesses in the given location and optionally scrapes their websites for emails, social profiles, and outgoing links. Returns immediately with a job_id; poll `/api/v1/jobs/{job_id}` until status=completed. If a job ends in `failed`, the credits charged for it are automatically refunded to your balance.\n\n## Synchronous mode (for LLM agents)\nSet `wait: true` to collapse the create-then-poll loop into a single call: the request blocks server-side until the job finishes, then returns the full results in the response - ideal for tool-calling agents.\n\n- **Completed in time -> 200** with the SAME envelope as `GET /api/v1/jobs/{job_id}` (job fields + full `businesses` array + coverage fields) plus `wait_timed_out: false`. You handle exactly one result schema whether you poll or wait.\n- **Job failed -> 200** with `status: \"failed\"` and `error` set. A job-level failure is NOT an HTTP 5xx; the call succeeded and the credits were auto-refunded.\n- **Timed out -> 202** with the normal accepted body plus `wait_timed_out: true`. The job is still running; fall back to polling `poll_url`.\n\nTune the block with `wait_timeout_seconds` (10-540, default 300). The cap keeps the request inside the platform's 10-minute limit. Combine with an `Idempotency-Key` to safely retry: a replay of an already-completed job returns its results immediately, and a replay of a still-running job re-enters the same wait.",
        "operationId": "createSearch",
        "parameters": [
          {
            "name": "Idempotency-Key",
            "in": "header",
            "schema": {
              "type": "string"
            },
            "required": false,
            "description": "Optional. If supplied and a prior request with the same key exists for this workspace, returns the existing job instead of creating a new one (safe retries)."
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/SearchRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "Either (a) an idempotent replay (Idempotency-Key matches a prior request), or (b) a synchronous (`wait: true`) request whose job reached a terminal state in time. In case (b) the body is the full `GET /api/v1/jobs/{job_id}` envelope (job fields + `businesses` array) with `wait_timed_out: false`; `status` is `completed` on success or `failed` (with `error` set, credits auto-refunded) on a job-level failure.",
            "content": {
              "application/json": {
                "schema": {
                  "oneOf": [
                    {
                      "$ref": "#/components/schemas/SearchAccepted"
                    },
                    {
                      "$ref": "#/components/schemas/Job"
                    }
                  ]
                }
              }
            }
          },
          "202": {
            "description": "Job accepted (async). Also returned in synchronous mode (`wait: true`) when the job does not finish before `wait_timeout_seconds`; in that case the body additionally carries `wait_timed_out: true` and you should fall back to polling `poll_url`.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SearchAccepted"
                }
              }
            }
          },
          "400": {
            "description": "Validation error. Also returned when `max_credits` is too low to afford even a single exhaustive search tile.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                },
                "examples": {
                  "maxCreditsTooLow": {
                    "value": {
                      "error": {
                        "code": "invalid_request",
                        "message": "`max_credits` (20) is below the cost of a single search tile (60 credits = result_pages x unique keyword queries x 20). Raise max_credits, lower result_pages, or use fewer keywords."
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "description": "Missing or invalid API key",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          },
          "402": {
            "description": "Insufficient credits",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                },
                "examples": {
                  "insufficientCredits": {
                    "value": {
                      "error": {
                        "code": "insufficient_credits",
                        "message": "Insufficient credits. This search costs 60 credits, but you have 20 available."
                      }
                    }
                  }
                }
              }
            }
          },
          "429": {
            "description": "Throttled. Either the per-key request rate limit was exceeded (`rate_limited`, see the Rate limits section - inspect `Retry-After` and the `X-RateLimit-*` headers) or the workspace's daily credit spend limit was reached (`daily_credit_limit_reached`).",
            "headers": {
              "Retry-After": {
                "$ref": "#/components/headers/Retry-After"
              },
              "X-RateLimit-Limit": {
                "$ref": "#/components/headers/X-RateLimit-Limit"
              },
              "X-RateLimit-Remaining": {
                "$ref": "#/components/headers/X-RateLimit-Remaining"
              },
              "X-RateLimit-Reset": {
                "$ref": "#/components/headers/X-RateLimit-Reset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                },
                "examples": {
                  "rateLimited": {
                    "value": {
                      "error": {
                        "code": "rate_limited",
                        "message": "Rate limit exceeded: 30 requests per minute for this API key. Retry after 12s."
                      }
                    }
                  },
                  "dailyCreditLimitReached": {
                    "value": {
                      "error": {
                        "code": "daily_credit_limit_reached",
                        "message": "Daily credit limit reached. This search costs 60 credits, but your plan allows 100 credits per UTC day and you have 20 remaining today."
                      }
                    }
                  }
                }
              }
            }
          }
        }
      }
    },
    "/api/v1/jobs": {
      "get": {
        "summary": "List recent jobs",
        "operationId": "listJobs",
        "parameters": [
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "schema": {
              "type": "integer",
              "minimum": 1,
              "maximum": 200,
              "default": 50
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Job list",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "jobs": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Job"
                      }
                    }
                  }
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/api/v1/jobs/{job_id}": {
      "get": {
        "summary": "Get a job by id",
        "description": "Returns the current status and all places found so far. Poll this endpoint until `status` is `completed` or `failed`.",
        "operationId": "getJob",
        "parameters": [
          {
            "name": "job_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "description": "Job details",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Job"
                }
              }
            }
          },
          "404": {
            "description": "Job not found",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      },
      "delete": {
        "summary": "Delete a job and all its results",
        "operationId": "deleteJob",
        "parameters": [
          {
            "name": "job_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "204": {
            "description": "Deleted"
          },
          "404": {
            "description": "Job not found",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/api/v1/jobs/{job_id}/export": {
      "get": {
        "summary": "Export a completed job's results as a file",
        "description": "Returns the job's businesses as a downloadable **CSV**, **XLSX** (Excel), or **JSON** file, generated server-side. Same Bearer auth and workspace scoping as `GET /api/v1/jobs/{job_id}`.\n\n## Formats (`?format=`)\n- `csv` (default) - RFC 4180, UTF-8 BOM, CRLF line endings (opens cleanly in Excel). `Content-Type: text/csv; charset=utf-8`.\n- `xlsx` - a real Office Open XML spreadsheet. `Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet`.\n- `json` - `{ job, businesses }` where each business is the same shape as the `businesses` array on `GET /api/v1/jobs/{job_id}`. `Content-Type: application/json; charset=utf-8`.\n\nAll responses carry `Content-Disposition: attachment; filename=\"<job-title-slug>-<jobid-prefix>.<ext>\"`.\n\n## Columns (CSV / XLSX)\n`Name`, `Address`, `Phone`, `Website`, `Best Email`, `Best Email Confidence`, `All Emails`, `Facebook`, `Instagram`, `LinkedIn`, `Twitter / X`, `YouTube`, `TikTok`, `Other Social`, `Latitude`, `Longitude`, `Rating`, `User Rating Count`, `Business Status`, `Primary Type`, `Types`, `Opening Hours`, `Open Now`, `Outgoing Links`, `Review Summary`, `Reviews`, `Render Used`. Multi-valued cells are joined with `; ` (opening-hour weekday descriptions and reviews with ` | `).\n\nExporting is only allowed once the job has finished: a non-`completed` job returns `409 job_not_completed`.",
        "operationId": "exportJob",
        "parameters": [
          {
            "name": "job_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          },
          {
            "name": "format",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "enum": [
                "csv",
                "xlsx",
                "json"
              ],
              "default": "csv"
            },
            "description": "Output file format. Defaults to `csv`."
          }
        ],
        "responses": {
          "200": {
            "description": "The export file as an attachment.",
            "content": {
              "text/csv": {
                "schema": {
                  "type": "string",
                  "format": "binary"
                }
              },
              "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": {
                "schema": {
                  "type": "string",
                  "format": "binary"
                }
              },
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "job",
                    "businesses"
                  ],
                  "properties": {
                    "job": {
                      "type": "object",
                      "properties": {
                        "id": {
                          "type": "string"
                        },
                        "title": {
                          "type": "string"
                        },
                        "status": {
                          "type": "string"
                        },
                        "location": {
                          "type": "string"
                        },
                        "keywords": {
                          "type": "array",
                          "items": {
                            "type": "string"
                          }
                        },
                        "results_count": {
                          "type": "integer"
                        },
                        "language_code": {
                          "type": [
                            "string",
                            "null"
                          ]
                        },
                        "coverage": {
                          "type": [
                            "string",
                            "null"
                          ]
                        },
                        "created_at": {
                          "type": [
                            "integer",
                            "null"
                          ]
                        },
                        "completed_at": {
                          "type": [
                            "integer",
                            "null"
                          ]
                        },
                        "exported_at": {
                          "type": "integer",
                          "description": "Epoch ms."
                        }
                      }
                    },
                    "businesses": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Business"
                      }
                    }
                  }
                }
              }
            }
          },
          "400": {
            "description": "Unsupported `format` value.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                },
                "examples": {
                  "invalidFormat": {
                    "value": {
                      "error": {
                        "code": "invalid_request",
                        "message": "Invalid `format`. Supported values are \"csv\", \"xlsx\", and \"json\" (default \"csv\")."
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "description": "Missing or invalid API key",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          },
          "404": {
            "description": "Job not found",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          },
          "409": {
            "description": "The job has not finished yet (cannot export partial results).",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                },
                "examples": {
                  "jobNotCompleted": {
                    "value": {
                      "error": {
                        "code": "job_not_completed",
                        "message": "Job is \"running\", not \"completed\". Wait for the job to finish before exporting."
                      }
                    }
                  }
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/api/v1/account": {
      "get": {
        "summary": "Get account, credits, and current-month usage",
        "operationId": "getAccount",
        "responses": {
          "200": {
            "description": "Account",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Account"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/api/v1/webhooks": {
      "get": {
        "summary": "List webhook endpoints",
        "description": "Lists the workspace's webhook endpoints. Secrets are never returned here.",
        "operationId": "listWebhooks",
        "responses": {
          "200": {
            "description": "Endpoint list",
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "properties": {
                    "webhooks": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/WebhookEndpoint"
                      }
                    }
                  }
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      },
      "post": {
        "summary": "Create a webhook endpoint",
        "description": "Registers an https endpoint to receive signed `job.completed` / `job.failed` events. The signing `secret` is returned ONCE in the response -- store it to verify deliveries.",
        "operationId": "createWebhook",
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/WebhookCreateRequest"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Endpoint created (secret revealed once)",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/WebhookEndpointCreated"
                }
              }
            }
          },
          "400": {
            "description": "Validation error",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/api/v1/webhooks/{webhook_id}": {
      "delete": {
        "summary": "Delete a webhook endpoint",
        "operationId": "deleteWebhook",
        "parameters": [
          {
            "name": "webhook_id",
            "in": "path",
            "required": true,
            "schema": {
              "type": "string"
            }
          }
        ],
        "responses": {
          "204": {
            "description": "Deleted"
          },
          "404": {
            "description": "Webhook not found",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorEnvelope"
                }
              }
            }
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    }
  },
  "webhooks": {
    "job.completed": {
      "post": {
        "summary": "Job completed",
        "description": "Sent to every enabled endpoint subscribed to `job.completed` once a collection job finishes successfully. Verify the signature before trusting the body.\n\n## Signature verification\nEach delivery carries these headers (Svix `svix-*` aliases are also sent):\n- `webhook-id`: unique message id\n- `webhook-timestamp`: unix seconds\n- `webhook-signature`: `v1,<base64 HMAC-SHA256>`\n\nThe signed content is `{webhook-id}.{webhook-timestamp}.{raw-body}`, HMAC-SHA256'd with the key obtained by base64-decoding the part of your secret after `whsec_`, then base64-encoded. This is the Standard Webhooks scheme, so the `svix` library verifies it directly: `new Webhook(secret).verify(rawBody, headers)`.",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/WebhookJobEvent"
              }
            }
          }
        },
        "responses": {
          "2XX": {
            "description": "Return any 2xx to acknowledge. Non-2xx / timeout triggers retries with exponential backoff (~0s, 1m, 10m, 1h, 6h; up to 5 attempts)."
          }
        }
      }
    },
    "job.failed": {
      "post": {
        "summary": "Job failed",
        "description": "Sent to every enabled endpoint subscribed to `job.failed` when a collection job ends in an error state. `data.error` carries the failure reason. Signature verification is identical to `job.completed`.",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/WebhookJobEvent"
              }
            }
          }
        },
        "responses": {
          "2XX": {
            "description": "Return any 2xx to acknowledge."
          }
        }
      }
    }
  }
}