{"openapi":"3.1.0","info":{"title":"iGregulator API","version":"1.0.0","description":"REST API for iGaming operator licence verification.\n\n**Legal notice.** Information returned by this API is sourced from public\ngambling-regulator registers and is provided for informational purposes only.\nResults do not constitute legal advice or regulatory guidance. Customers are\nresponsible for their own compliance, KYB, and AML decisions and should\nconfirm critical licensing decisions against the issuing regulator. Full terms\nat https://igregulator.io/terms; coverage methodology at\nhttps://igregulator.io/docs/coverage-methodology.\n\nPublic endpoints are rate-limited per IP; authenticated endpoints are\nrate-limited per plan tier. See the rate-limit section of the guides at\nhttps://igregulator.io/docs/rate-limits for the tier tables.\n\nAuthentication uses Bearer tokens — create a free account at\nhttps://app.igregulator.io/signup (founding members get the full Starter\nplan free, no card) and generate an API key at\nhttps://app.igregulator.io/api-keys.","contact":{"email":"founder@igregulator.io","url":"https://igregulator.io"},"license":{"name":"Proprietary","url":"https://igregulator.io/terms"}},"servers":[{"url":"https://api.igregulator.io","description":"Production. Example: curl https://api.igregulator.io/v1/check?domain=bet365.com"}],"tags":[{"name":"Lookup","description":"Public-facing verification endpoints."},{"name":"Jurisdictions","description":"Regulator metadata and per-jurisdiction operator listings."},{"name":"Operators","description":"Operator search + detail."},{"name":"Licences","description":"Per-licence detail + status history."},{"name":"Watchlist","description":"Per-user watchlist CRUD plus polling fallback for the event feed. Operators on a user's watchlist drive the `watchlist_only` filter on webhooks."},{"name":"Webhooks","description":"Push-based alerts. Create endpoints, rotate signing secrets, fire test events, replay deliveries. Full protocol at https://igregulator.io/docs/webhooks."},{"name":"System","description":"Health and spec endpoints."}],"components":{"securitySchemes":{"bearerAuth":{"type":"http","scheme":"bearer","description":"API key created at https://app.igregulator.io/api-keys (free — founding members get the full Starter plan), sent as `Authorization: Bearer <key>`.\n\nPer-plan rate limits apply downstream of this check — Starter 10k /\nmonth, Pro 100k / month, Business fair-use. See\nhttps://igregulator.io/docs/rate-limits for details."}},"schemas":{"ApiErrorDetails":{"type":"object","required":["reason"],"properties":{"reason":{"type":"string","description":"Machine-readable refinement of the top-level `code`. Stable vocabulary; branch on this in clients. Examples: `invalid_input`, `missing_required_parameter`, `conflicting_parameters`, `operator_not_found`, `license_not_found`, `jurisdiction_not_found`, `route_not_found`, `api_key_missing`, `malformed_header`, `api_key_invalid`, `api_key_revoked`, `quota_exceeded`, `internal_error`."},"field":{"type":"string","description":"Present only when the error maps to a specific request input field (query param, path param, body key). Omitted for errors that aren't field-scoped (e.g. `rate_limited`, `auth_revoked`)."},"suggestion":{"type":"string","description":"Optional human-readable / agent-actionable hint describing how to resolve the error."}},"example":{"reason":"api_key_revoked","suggestion":"Generate a new API key at https://app.igregulator.io/settings. Revoked keys cannot be restored."}},"ApiError":{"type":"object","required":["error","code","details"],"properties":{"error":{"type":"string","description":"Human-readable error summary."},"code":{"type":"string","description":"HTTP-status-level class. Stable enum; branch on `details.reason` for finer control. Current values: `invalid_query`, `invalid_slug`, `invalid_license_id`, `invalid_jurisdiction_code`, `invalid_pagination`, `not_found`, `auth_required`, `auth_invalid`, `auth_revoked`, `payment_required`, `quota_exceeded`, `rate_limited`, `server_error`."},"details":{"$ref":"#/components/schemas/ApiErrorDetails"}},"example":{"error":"API key has been revoked","code":"auth_revoked","details":{"reason":"api_key_revoked","suggestion":"Generate a new API key at https://app.igregulator.io/settings. Revoked keys cannot be restored."}}},"SingleMeta":{"type":"object","required":["scraped_at","source_modified_at","source_url","confidence_hint"],"description":"Single-resource provenance envelope — answers \"where did this come from and how fresh is it?\" for one record.","properties":{"scraped_at":{"type":"string","format":"date-time","description":"ISO-8601 timestamp when iGregulator's scraper last fetched this record."},"source_modified_at":{"type":["string","null"],"format":"date-time","description":"ISO-8601 timestamp when the regulator updated the data on their register, not when our scraper fetched it. Always null today across every jurisdiction — our schema doesn't persist per-record upstream modification time. Will populate for UKGC (the one regulator that exposes it via the ZIP's `last_updated` field) once the scraper migration lands; remains null for MGA, CGA, KGC (no upstream timestamp)."},"source_url":{"type":["string","null"],"format":"uri","description":"URL of the source register page or dump. Null when not attributable to a single URL."},"confidence_hint":{"type":"string","enum":["authoritative","scraped","derived"],"description":"`authoritative` — direct from an official regulator dump (e.g. UKGC ZIP). `scraped` — parsed from regulator HTML/PDF (MGA, CGA, KGC). `derived` — computed match, not a direct lookup (fuzzy match on /v1/check, no direct source row)."}},"example":{"scraped_at":"2026-04-19T03:00:00Z","source_modified_at":null,"source_url":"https://www.gamblingcommission.gov.uk/downloads/business-licence-data.zip","confidence_hint":"authoritative"}},"ListMeta":{"type":"object","required":["freshness_range","total_sources"],"description":"Aggregate provenance envelope for list responses. Describes the freshness window of the returned rows.","properties":{"freshness_range":{"type":"object","required":["oldest","newest"],"properties":{"oldest":{"type":["string","null"],"format":"date-time"},"newest":{"type":["string","null"],"format":"date-time"}}},"total_sources":{"type":"integer","description":"Distinct upstream sources represented by this page of rows (roughly: distinct jurisdictions)."}},"example":{"freshness_range":{"oldest":"2026-04-18T03:00:00Z","newest":"2026-04-19T03:45:00Z"},"total_sources":1}},"LicenseStatus":{"type":"string","enum":["active","suspended","revoked","expired","pending"]},"Jurisdiction":{"type":"object","properties":{"code":{"type":"string","examples":["UKGC"]},"name":{"type":"string","examples":["UK Gambling Commission"]},"country_code":{"type":"string","examples":["GB"]},"website":{"type":"string","format":"uri"},"currency":{"type":"string","examples":["GBP"]},"license_types":{"type":"array","items":{"type":"string"}}},"example":{"code":"UKGC","name":"UK Gambling Commission","country_code":"GB","website":"https://www.gamblingcommission.gov.uk","currency":"GBP","license_types":["Remote","Non-Remote","Ancillary"]}},"Operator":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"slug":{"type":"string","examples":["power-leisure-bookmakers-limited"]},"display_name":{"type":"string","examples":["Power Leisure Bookmakers Limited"]},"registered_name":{"type":["string","null"]},"country":{"type":["string","null"]},"upstream_ids":{"type":"object","additionalProperties":{"type":"string"}},"trading_names":{"type":"array","items":{"type":"string"}},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"}},"example":{"id":"9d6f21e0-7f34-4b3a-b0a8-ec6c3cab3e11","slug":"power-leisure-bookmakers-limited","display_name":"Power Leisure Bookmakers Limited","registered_name":"paddy power","country":"GB","upstream_ids":{"UKGC":"39028"},"trading_names":["paddy power"],"created_at":"2026-04-17T15:15:41.414Z","updated_at":"2026-04-19T03:01:12.200Z"}},"License":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"license_number":{"type":"string","examples":["039028-R-319297-013"]},"jurisdiction_code":{"type":"string","examples":["UKGC"]},"status":{"$ref":"#/components/schemas/LicenseStatus"},"license_types":{"type":"array","items":{"type":"string"},"description":"Multi-valued type vocabulary, sourced verbatim from the regulator. Stable values per jurisdiction (audited 2026-04-21):\n\n- **UKGC** — `Remote`, `Non-Remote`, `Ancillary Remote`\n- **MGA** — `Type 1`, `Type 2`, `Type 3`, `Type 4`, `B2B`, `B2C`\n- **CW** (Curaçao) — `B2C`, `B2B`\n- **KH** (Kahnawake) — `Interactive Gaming Permit`, `CSPA`\n- **AN** (Anjouan) — `B2C`, `B2B`, `White Labeling`\n- **TGC** (Tobique) — `B2C`, `B2B`\n\nAdd a new value to your client mapping when a regulator publishes one — we don't reject unknown strings. Multi-jurisdiction operators carry one license row per jurisdiction, so this array is per-licence."},"license_type_raw":{"type":"string"},"license_category":{"type":"string","enum":["remote","non-remote","ancillary","permit","other"],"description":"Cross-jurisdiction harmonised category — derived by `@igregulator/normalizer` so a multi-regulator query can group by it. UKGC `Remote`, MGA `Type 1/2`, CW `B2C` map to `remote`; UKGC `Non-Remote` to `non-remote`; etc."},"issued_date":{"type":["string","null"],"format":"date"},"expiry_date":{"type":["string","null"],"format":"date"},"last_verified_at":{"type":"string","format":"date-time"},"source_url":{"type":"string","format":"uri"},"raw_data":{"type":"object","additionalProperties":true,"description":"Scraper-specific fields captured verbatim from the upstream register (e.g. UKGC activity categories, CGA company-type flags). **Not a stable integration surface.** Keys here are added, renamed, or removed without a deprecation window when a scraper is updated — they do not carry the 90-day Sunset guarantee that top-level fields do. Use `license_types`, `license_category`, `status`, `issued_date`, `expiry_date` for stable compliance logic; read `raw_data` only for diagnostic / investigative purposes."},"as_of":{"$ref":"#/components/schemas/AsOf"}},"example":{"id":"d29fbc19-3ed3-4043-8a74-e001491feb24","license_number":"039028-R-319297-013","jurisdiction_code":"UKGC","status":"active","license_types":["Remote"],"license_type_raw":"Remote","license_category":"remote","issued_date":"2014-11-01","expiry_date":null,"last_verified_at":"2026-04-19T03:00:12.480Z","source_url":"https://www.gamblingcommission.gov.uk/downloads/business-licence-data.zip","raw_data":{"activities":["Bingo","Casino"]}}},"LicenseHistoryEntry":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"changed_at":{"type":"string","format":"date-time"},"previous_status":{"oneOf":[{"$ref":"#/components/schemas/LicenseStatus"},{"type":"null"}]},"new_status":{"$ref":"#/components/schemas/LicenseStatus"},"change_type":{"type":"string","enum":["created","status_change","renewed","revoked","data_update"]},"source_url":{"type":"string"},"detected_by":{"type":"string"}}},"CheckMatch":{"type":"object","required":["confidence","match_type","operator","operator_slug"],"properties":{"confidence":{"type":"string","enum":["high","medium","low"],"description":"`high` = domain matched exactly in our registry. `medium` = domain root matched a trading name or operator name (we can identify the operator but not prove the domain is theirs). `low` = the domain root is a generic gambling term (`casino.com`, `poker.com`), so we refuse to name an operator."},"match_type":{"type":"string","enum":["domain_exact","trading_name_fuzzy","name_similarity"]},"operator":{"type":"string"},"operator_slug":{"type":"string"},"jurisdiction":{"type":["string","null"]},"license_number":{"type":["string","null"]},"status":{"type":["string","null"]},"expires_at":{"type":["string","null"],"format":"date"},"domain_association":{"type":["string","null"],"enum":["direct","white_label",null],"description":"Populated for `domain_exact` matches only. `direct` = licensee runs the domain; `white_label` = licensee authorises a third-party brand on the domain. `null` for fuzzy matches."}}},"AsOf":{"type":"object","description":"Point-in-time status, present only when `?as_of=` was supplied. Answers ONLY within the observation window: we never extrapolate a status before `tracking_since` (the first time we recorded the licence).","required":["as_of","knowledge","status_as_of","established_by","tracking_since"],"properties":{"as_of":{"type":"string","format":"date-time","description":"The resolved instant (UTC). Bare YYYY-MM-DD inputs resolve to end-of-day."},"knowledge":{"type":"string","enum":["observed","before_tracking","no_such_license","no_license_resolved"],"description":"`observed`: the date is within our window, status is known. `before_tracking`: the date predates when we started watching — status is null, NOT a guess. `no_such_license`: we have no history for this licence. `no_license_resolved`: a fuzzy /v1/check match with no specific licence to time-travel."},"status_as_of":{"type":["string","null"],"description":"Licence status as of the date, or null when not observed."},"established_by":{"type":["object","null"],"description":"The history transition in effect at the date — when this status was last confirmed relative to the query.","properties":{"changed_at":{"type":"string","format":"date-time"},"new_status":{"type":"string"},"change_type":{"type":"string"},"source_url":{"type":"string"}}},"tracking_since":{"type":["string","null"],"format":"date-time","description":"Lower bound of our knowledge — when we first observed this licence."}}},"CheckAlternative":{"type":"object","required":["operator","operator_slug","matched_name","similarity"],"properties":{"operator":{"type":"string"},"operator_slug":{"type":"string"},"matched_name":{"type":"string"},"similarity":{"type":"number","format":"float","minimum":0,"maximum":1}}},"CheckResult":{"type":"object","required":["query","match","alternatives","_meta"],"properties":{"query":{"type":"object","properties":{"domain":{"type":"string"},"license_number":{"type":"string"}}},"match":{"oneOf":[{"$ref":"#/components/schemas/CheckMatch"},{"type":"null"}]},"alternatives":{"type":"array","items":{"$ref":"#/components/schemas/CheckAlternative"},"maxItems":3},"confidence":{"type":"string","enum":["high","medium","low","none"],"description":"Mirror of `match.confidence`, or `none` when no match."},"match_absence_reason":{"type":["string","null"],"enum":["generic_term","no_record_found",null],"description":"Present only when `match` is null. `generic_term`: the label is an ultra-generic gambling word (e.g. casino.com) we cannot map to one operator. `no_record_found`: a specific query we checked against every covered register and did not find. Lets a caller phrase the answer precisely instead of treating every miss as \"unlicensed\"."},"checked_jurisdictions":{"type":"array","items":{"type":"string"},"description":"Present only when `match` is null — the jurisdiction codes actually checked, so a \"not licensed\" claim stays scoped to our coverage. Example: [\"AN\",\"CW\",\"KH\",\"MGA\",\"TGC\",\"UKGC\"]."},"as_of":{"$ref":"#/components/schemas/AsOf"},"_meta":{"$ref":"#/components/schemas/SingleMeta"}},"example":{"query":{"domain":"paddypower.com"},"match":{"confidence":"medium","match_type":"trading_name_fuzzy","operator":"Power Leisure Bookmakers Limited","operator_slug":"power-leisure-bookmakers-limited","jurisdiction":"UKGC","license_number":"039028-R-319297-013","status":"active","expires_at":null,"domain_association":null},"alternatives":[{"operator":"PPB Counterparty Services Limited","operator_slug":"ppb-counterparty-services-limited","matched_name":"paddy power","similarity":1}],"confidence":"medium","_meta":{"scraped_at":"2026-04-19T16:42:10.000Z","source_modified_at":null,"source_url":null,"confidence_hint":"derived"}}},"OperatorWithMeta":{"description":"Operator row plus per-item provenance `_meta`. Used inside `OperatorSearchResult.operators[]`.","allOf":[{"$ref":"#/components/schemas/Operator"},{"type":"object","required":["_meta"],"properties":{"_meta":{"$ref":"#/components/schemas/SingleMeta"}}}]},"OperatorSearchResult":{"type":"object","required":["q","total","limit","offset","operators","_meta"],"properties":{"q":{"type":"string"},"total":{"type":"integer"},"limit":{"type":"integer","description":"Effective limit applied. Unauthenticated callers are capped at 3 rows regardless of the `limit` query param."},"offset":{"type":"integer"},"operators":{"type":"array","items":{"$ref":"#/components/schemas/OperatorWithMeta"}},"_meta":{"$ref":"#/components/schemas/ListMeta"}}},"OperatorDetail":{"type":"object","required":["operator","licenses","domains","_meta"],"properties":{"operator":{"$ref":"#/components/schemas/Operator"},"licenses":{"type":"array","items":{"$ref":"#/components/schemas/License"}},"domains":{"type":"array","items":{"type":"object","properties":{"domain":{"type":"string"},"status":{"type":"string"},"association":{"type":"string","enum":["direct","white_label"]},"first_seen":{"type":"string","format":"date-time"},"last_verified":{"type":"string","format":"date-time"}}}},"_meta":{"oneOf":[{"$ref":"#/components/schemas/SingleMeta"},{"type":"null"}],"description":"Null when the operator has no licences; otherwise derived from the operator's most recently-verified active licence."}}},"LicenseDetail":{"description":"License with single-resource provenance `_meta`. Used by `/v1/licenses/:license_id`.","allOf":[{"$ref":"#/components/schemas/License"},{"type":"object","required":["_meta","operator_id","operator_slug","operator_name"],"properties":{"operator_id":{"type":"string","format":"uuid"},"operator_slug":{"type":"string"},"operator_name":{"type":"string"},"confidence_score":{"type":"number"},"_meta":{"$ref":"#/components/schemas/SingleMeta"}}}]},"LicenseHistoryResponse":{"type":"object","required":["license_id","license_number","history","_meta"],"properties":{"license_id":{"type":"string","format":"uuid"},"license_number":{"type":"string"},"history":{"type":"array","items":{"$ref":"#/components/schemas/LicenseHistoryEntry"}},"_meta":{"$ref":"#/components/schemas/ListMeta"}}},"HealthResult":{"type":"object","required":["status"],"properties":{"status":{"type":"string","enum":["ok","degraded"]},"checks":{"type":"object","additionalProperties":{"type":"boolean"},"description":"Per-dependency boolean map — e.g. `{ postgres: true, redis: true }`. Keys stable across releases."}},"example":{"status":"ok","checks":{"postgres":true,"redis":true}}},"WatchlistOperator":{"type":"object","required":["id","slug","display_name","added_at"],"properties":{"id":{"type":"string","format":"uuid"},"slug":{"type":"string"},"display_name":{"type":"string"},"country":{"type":["string","null"]},"added_at":{"type":"string","format":"date-time"},"license_status":{"type":["string","null"],"enum":["active","suspended","revoked","expired","pending",null]},"jurisdiction_code":{"type":["string","null"]}}},"WebhookEventType":{"type":"string","enum":["license.status_changed","license.expiring_30d","license.expiring_60d","license.expiring_90d","license.expired","license.issued","regulatory_action.added","coverage.degraded","coverage.restored","webhook.endpoint_degraded"]},"WebhookCreate":{"type":"object","required":["url","events"],"properties":{"url":{"type":"string","format":"uri","maxLength":2048,"description":"Public https URL. Resolved against the SSRF blocklist at creation AND every delivery."},"description":{"type":"string","maxLength":500},"events":{"type":"array","minItems":1,"items":{"$ref":"#/components/schemas/WebhookEventType"}},"watchlist_only":{"type":"boolean","default":true,"description":"When true (default), tenant-scoped events (license.*, regulatory_action.*) fire only for operators in the caller's watchlist. Global events (coverage.*, webhook.endpoint_degraded) ignore this flag."}}},"WebhookPatch":{"type":"object","properties":{"url":{"type":"string","format":"uri","maxLength":2048},"description":{"type":["string","null"],"maxLength":500},"events":{"type":"array","minItems":1,"items":{"$ref":"#/components/schemas/WebhookEventType"}},"active":{"type":"boolean"},"watchlist_only":{"type":"boolean"}},"additionalProperties":false},"WebhookEndpoint":{"type":"object","required":["id","url","events","active","watchlist_only","created_at"],"properties":{"id":{"type":"string","format":"uuid"},"url":{"type":"string","format":"uri"},"description":{"type":["string","null"]},"events":{"type":"array","items":{"$ref":"#/components/schemas/WebhookEventType"}},"active":{"type":"boolean"},"watchlist_only":{"type":"boolean"},"created_at":{"type":"string","format":"date-time"},"last_delivered_at":{"type":["string","null"],"format":"date-time"},"last_delivery_status":{"type":["string","null"],"enum":["success","failed","retrying",null]},"failure_count":{"type":"integer"}}},"WebhookDelivery":{"type":"object","required":["id","event_id","event_type","status","attempt_count","created_at"],"properties":{"id":{"type":"string","format":"uuid"},"event_id":{"type":"string","description":"ULID, matches `event_id` inside the payload envelope. Use this as the dedupe key on the receiver side."},"event_type":{"$ref":"#/components/schemas/WebhookEventType"},"attempt_count":{"type":"integer"},"last_attempt_at":{"type":["string","null"],"format":"date-time"},"next_retry_at":{"type":"string","format":"date-time"},"status":{"type":"string","enum":["pending","delivered","failed","abandoned"]},"last_response_status":{"type":["integer","null"]},"created_at":{"type":"string","format":"date-time"},"delivered_at":{"type":["string","null"],"format":"date-time"}}},"WebhookEventEnvelope":{"type":"object","description":"Common shape across every webhook event (and every entry in `/v1/watchlist/events`). The `data` block varies by `event` — see https://igregulator.io/docs/webhooks for per-event field tables.","required":["event","event_id","api_version","timestamp","livemode","data"],"properties":{"event":{"$ref":"#/components/schemas/WebhookEventType"},"event_id":{"type":"string","example":"evt_01HX8EGQK3J7WA6MYTP7ZGYF21"},"api_version":{"type":"string","example":"2026-04-20"},"timestamp":{"type":"string","format":"date-time"},"livemode":{"type":"boolean"},"data":{"type":"object","additionalProperties":true}},"example":{"event":"license.status_changed","event_id":"evt_01HX8EGQK3J7WA6MYTP7ZGYF21","api_version":"2026-04-20","timestamp":"2026-04-20T14:32:00.000Z","livemode":true,"data":{"license_id":"4db0141c-a863-49b1-808d-75fa49e36fd9","license_number":"039028-R-319297-013","operator_id":"00000000-0000-0000-0000-000000000000","operator_slug":"888-uk-limited","jurisdiction_code":"UKGC","previous_status":"active","new_status":"suspended","changed_at":"2026-04-20T03:04:12.000Z","source_url":"https://www.gamblingcommission.gov.uk/..."}}}},"responses":{"BadRequest":{"description":"Invalid query / parameters.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"Unauthorized":{"description":"Missing / malformed / revoked API key.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"NotFound":{"description":"No row matched.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"RateLimited":{"description":"Rate limit reached. Public endpoints: 10 req / IP / hour. Authenticated: per plan tier. Retry after the window surfaced in `X-RateLimit-Reset`.","headers":{"X-RateLimit-Limit":{"schema":{"type":"integer"}},"X-RateLimit-Remaining":{"schema":{"type":"integer"}},"X-RateLimit-Reset":{"schema":{"type":"integer"},"description":"Unix epoch seconds."},"X-Upgrade-URL":{"schema":{"type":"string","format":"uri"}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"ServerError":{"description":"Unexpected server error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"Conflict":{"description":"Resource state conflict — e.g. operator already on the watchlist, endpoint with no active secret.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"QuotaExceeded":{"description":"Plan-tier quota reached. Body carries `details.reason` (`watchlist_quota_exceeded` / `webhook_quota_exceeded`) plus `current_usage` / `limit` for client-side display.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}},"PaymentRequired":{"description":"Subscription not active (canceled / null plan_tier). Resubscribe at https://igregulator.io/pricing.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiError"}}}}}},"paths":{"/v1/health":{"get":{"operationId":"checkHealth","security":[],"tags":["System"],"summary":"Liveness + dependency health probe","description":"Returns 200 with a per-dependency boolean map when postgres + redis are reachable; 503 with the same shape when at least one dependency is down. Unauthenticated — suitable for uptime probes. Response keys (`postgres`, `redis`) are stable across releases; new dependencies will be added without breaking existing keys.","responses":{"200":{"description":"All dependencies healthy.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HealthResult"}}}},"503":{"description":"One or more dependencies unhealthy.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HealthResult"}}}}}}},"/v1/health/coverage":{"get":{"operationId":"checkCoverage","security":[],"tags":["System"],"summary":"Per-jurisdiction scraper freshness","description":"Public endpoint. Exposes the age of the most recent successful\nscraper run per jurisdiction so consumers can see the freshness\nguarantee substantiated without provisioning a key.\n\n**Freshness SLA** — UKGC (direct ZIP download) is considered fresh\nwhile `age_hours < 24`; scraped sources (MGA, CW, KH) while\n`age_hours < 48`. `status` is `healthy` when every jurisdiction\nmeets its SLA, `degraded` when any is stale.\n\n**Rate limit:** 10 requests / IP / hour (unauthenticated).\n\nEach jurisdiction also carries a `domain_coverage` object: the share of\n*consumer-facing* operators (license-type-normative subset) that have\n≥1 domain in our DB. See [coverage methodology](https://igregulator.io/docs/coverage-methodology/)\nfor the per-jurisdiction filter and reading guidance.","responses":{"200":{"description":"Coverage freshness map.","content":{"application/json":{"schema":{"type":"object","required":["status","jurisdictions","overall_freshness_sla","timestamp"],"properties":{"status":{"type":"string","enum":["healthy","degraded"]},"overall_freshness_sla":{"type":"string"},"jurisdictions":{"type":"object","additionalProperties":{"type":"object","required":["sla_hours","record_count"],"properties":{"last_successful_scrape":{"type":"string","format":"date-time","nullable":true},"age_hours":{"type":"integer","nullable":true},"fresh":{"type":"boolean","nullable":true},"sla_hours":{"type":"integer"},"record_count":{"type":"integer"},"domain_coverage":{"type":"object","description":"Domain-coverage metric scoped to operators where domain disclosure is normative for their license type. See https://igregulator.io/docs/coverage-methodology/","properties":{"operators_with_domain":{"type":"integer"},"normative_operators":{"type":"integer"},"coverage_pct":{"type":"integer","nullable":true}}},"reason":{"type":"string"}}}},"timestamp":{"type":"string","format":"date-time"}}}}}},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/ServerError"}}}},"/v1/check":{"get":{"operationId":"checkDomain","security":[],"tags":["Lookup"],"summary":"Verify a domain or licence number","description":"Public endpoint. Pass `?domain=` **or** `?license_number=`, not both.\n\n**Rate limit:** 10 requests / IP / hour (unauthenticated). Authenticated\ncallers skip the IP gate and use their plan quota.\n\n**Response:** `{ query, match, alternatives, confidence }`. See the\nconfidence-scoring guide at https://igregulator.io/docs/confidence for\nmatch-type semantics.\n\n**Headers on every response:** `X-RateLimit-Limit`, `X-RateLimit-Remaining`,\n`X-RateLimit-Reset`, `X-Upgrade-URL`.","parameters":[{"name":"domain","in":"query","schema":{"type":"string","maxLength":253},"example":"bet365.com"},{"name":"license_number","in":"query","schema":{"type":"string","maxLength":64},"example":"039028-R-319297-013"},{"name":"as_of","in":"query","schema":{"type":"string","format":"date","pattern":"^\\d{4}-\\d{2}-\\d{2}$"},"example":"2026-03-01","description":"Retrospective lookup. Reconstructs the matched licence's status as it stood on this UTC date from transition history — e.g. \"was this operator licensed at the time of a transaction three months ago\". `match.status` stays current; the historical value is returned under a top-level `as_of` object."}],"responses":{"200":{"description":"Lookup result (may indicate `confidence: \"none\"`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CheckResult"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/ServerError"}}}},"/v1/check/batch":{"post":{"operationId":"checkDomainBatch","security":[{"bearerAuth":[]}],"tags":["Lookup"],"summary":"Verify many domains in one request (KYB sweep)","description":"Authenticated batch of `/v1/check` (domains only) — one round trip for a KYB sweep or affiliate-list audit instead of N sequential calls. Each result mirrors the single-check shape (`match` / `confidence` / `match_absence_reason`); `checked_jurisdictions` is returned once at the top to stay token-lean. Up to 100 domains per request — paginate beyond. Invalid hostnames come back as a per-row `error` rather than failing the whole batch. Counts as one request against your plan quota today.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["domains"],"properties":{"domains":{"type":"array","items":{"type":"string","maxLength":253},"minItems":1,"maxItems":100}}},"example":{"domains":["bet365.com","www.virginbet.com","casino.com"]}}}},"responses":{"200":{"description":"Per-domain results.","content":{"application/json":{"schema":{"type":"object","required":["count","checked_jurisdictions","results"],"properties":{"count":{"type":"integer"},"checked_jurisdictions":{"type":"array","items":{"type":"string"}},"results":{"type":"array","items":{"type":"object","required":["query","match","confidence"],"properties":{"query":{"type":"object","properties":{"domain":{"type":"string"}}},"match":{"oneOf":[{"$ref":"#/components/schemas/CheckMatch"},{"type":"null"}]},"confidence":{"type":"string","enum":["high","medium","low","none"]},"match_absence_reason":{"type":["string","null"],"enum":["generic_term","no_record_found",null]},"error":{"type":"string","description":"Set when the entry is not a valid hostname (e.g. `invalid_hostname`); `match` is null."}}}}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"402":{"$ref":"#/components/responses/PaymentRequired"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/ServerError"}}}},"/v1/jurisdictions":{"get":{"operationId":"listJurisdictions","security":[],"tags":["Jurisdictions"],"summary":"List all covered jurisdictions","description":"Returns every regulator iGregulator has coverage data for, with display name, country code, website, accepted currency, and the licence-type vocabulary the regulator uses. Intended as a directory for clients building jurisdiction pickers or validating a licence payload against the regulator's own taxonomy. Public endpoint, 10 req / IP / hour.","responses":{"200":{"description":"Jurisdictions array.","content":{"application/json":{"schema":{"type":"object","properties":{"jurisdictions":{"type":"array","items":{"$ref":"#/components/schemas/Jurisdiction"}}}}}}},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/ServerError"}}}},"/v1/jurisdictions/{code}":{"get":{"operationId":"getJurisdiction","tags":["Jurisdictions"],"summary":"Jurisdiction detail","description":"Return metadata for a single jurisdiction — name, country, currency, and the licence-type vocabulary it uses. Agents typically call this after `listJurisdictions` to dereference a code that came from a licence record.","security":[{"bearerAuth":[]}],"parameters":[{"name":"code","in":"path","required":true,"schema":{"type":"string","maxLength":16},"example":"UKGC"}],"responses":{"200":{"description":"Jurisdiction.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Jurisdiction"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/ServerError"}}}},"/v1/jurisdictions/{code}/operators":{"get":{"operationId":"listJurisdictionOperators","tags":["Jurisdictions"],"summary":"List operators under a jurisdiction (paginated)","description":"Every operator that holds at least one licence under the given jurisdiction. Paginate with `?limit=` (1–200, default 50) and `?offset=` (zero-based). Sorted by `display_name` ascending. Typical agent use: \"give me the current UKGC-licensed operator roster\" — walk this endpoint and cache the result.","security":[{"bearerAuth":[]}],"parameters":[{"name":"code","in":"path","required":true,"schema":{"type":"string"}},{"name":"limit","in":"query","schema":{"type":"integer","default":50,"minimum":1,"maximum":200}},{"name":"offset","in":"query","schema":{"type":"integer","default":0,"minimum":0}}],"responses":{"200":{"description":"Paginated operators list."},"401":{"$ref":"#/components/responses/Unauthorized"},"500":{"$ref":"#/components/responses/ServerError"}}}},"/v1/operators/search":{"get":{"operationId":"searchOperators","security":[],"tags":["Operators"],"summary":"Search operators by name or trading name","description":"Public endpoint. Results ranked: exact slug match > display-name prefix > ILIKE fallback.\n\n**Rate limit:** 10 req / IP / hour unauthenticated. Unauthenticated callers\nare also capped at `limit=3` regardless of what `?limit=` requested.\n\nAuthenticated callers: per-plan rate limit, `limit` up to 200.","parameters":[{"name":"q","in":"query","required":true,"schema":{"type":"string"},"example":"888"},{"name":"limit","in":"query","schema":{"type":"integer","default":50}},{"name":"offset","in":"query","schema":{"type":"integer","default":0}}],"responses":{"200":{"description":"Search result.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OperatorSearchResult"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/ServerError"}}}},"/v1/operators/{slug}":{"get":{"operationId":"getOperator","tags":["Operators"],"summary":"Operator detail — metadata + licences + domains","description":"Full profile for one operator: display/registered/trading names, upstream regulator IDs, every licence the operator holds, and every domain we've attributed to them. The response envelope includes a single-resource `_meta` describing the freshness + confidence of the primary active licence. Most-common agent intent: \"tell me everything you know about this operator\". Pass `?as_of=` to attach a point-in-time status to EACH licence (resolved per-licence, never collapsed into one operator status).","security":[{"bearerAuth":[]}],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}},{"name":"as_of","in":"query","schema":{"type":"string"},"example":"2026-03-01","description":"Point-in-time lookup. Each licence gains an `as_of` object (status reconstructed within our observation window — never extrapolated before `tracking_since`). Bare YYYY-MM-DD = end of day UTC; ISO datetime supported; a future value 400s."}],"responses":{"200":{"description":"Operator detail.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OperatorDetail"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/ServerError"}}}},"/v1/operators/{slug}/licenses":{"get":{"operationId":"listOperatorLicenses","tags":["Operators"],"summary":"Licences for an operator (paginated)","description":"Every licence the operator holds, sorted by `jurisdiction_code, license_number`. Append `?include_history=true` to embed the status-change timeline under each licence; omit for the slimmer detail view. Agents that want a cross-jurisdiction compliance view should call this instead of the single-licence endpoint.","security":[{"bearerAuth":[]}],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}},{"name":"include_history","in":"query","schema":{"type":"boolean","default":false},"description":"When true, each licence includes its status-change `history[]`."}],"responses":{"200":{"description":"Paginated licences for the operator.","content":{"application/json":{"schema":{"type":"object","required":["operator_slug","licenses","total","limit","offset"],"properties":{"operator_slug":{"type":"string"},"total":{"type":"integer"},"limit":{"type":"integer"},"offset":{"type":"integer"},"licenses":{"type":"array","items":{"$ref":"#/components/schemas/License"}},"_meta":{"oneOf":[{"$ref":"#/components/schemas/ListMeta"},{"type":"null"}]}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/ServerError"}}}},"/v1/operators/{slug}/regulatory-actions":{"get":{"operationId":"getOperatorRegulatoryActions","tags":["Operators"],"summary":"Regulatory actions / enforcement history for an operator (paginated)","description":"Fines, suspensions, revocations, settlements and public warnings recorded against the operator across jurisdictions — newest first by default. This is the enforcement-history signal for KYB / reputation scoring; it is intentionally NOT embedded in the operator-detail response (GET /v1/operators/{slug}), so fetch it separately.","security":[{"bearerAuth":[]}],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}},{"name":"limit","in":"query","schema":{"type":"integer","minimum":1,"maximum":100,"default":20}},{"name":"offset","in":"query","schema":{"type":"integer","minimum":0,"default":0}},{"name":"sort","in":"query","schema":{"type":"string","enum":["date_desc","date_asc","amount_desc","type_asc"],"default":"date_desc"},"description":"Ordering. amount_desc sorts by fine size (null amounts last)."}],"responses":{"200":{"description":"Paginated regulatory actions for the operator.","content":{"application/json":{"schema":{"type":"object","required":["operator_slug","total","limit","offset","sort","regulatory_actions"],"properties":{"operator_slug":{"type":"string"},"total":{"type":"integer"},"limit":{"type":"integer"},"offset":{"type":"integer"},"sort":{"type":"string","enum":["date_desc","date_asc","amount_desc","type_asc"]},"regulatory_actions":{"type":"array","items":{"type":"object","required":["id","jurisdiction_code","action_type","outcomes","decision_date","source_url","subject_name"],"properties":{"id":{"type":"string","format":"uuid"},"jurisdiction_code":{"type":"string"},"action_type":{"type":"string"},"outcomes":{"type":"array","items":{"type":"string"}},"fine_amount_cents":{"type":["integer","null"]},"fine_currency":{"type":["string","null"]},"decision_date":{"type":"string","format":"date"},"description":{"type":["string","null"]},"source_url":{"type":"string","format":"uri"},"subject_name":{"type":"string"},"first_seen_at":{"type":"string","format":"date-time"},"last_verified_at":{"type":"string","format":"date-time"}}}}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/ServerError"}}}},"/v1/licenses/{license_id}":{"get":{"operationId":"getLicense","tags":["Licences"],"summary":"Licence detail by id","description":"Returns a single licence by its iGregulator-assigned UUID (not the human-readable licence number). Includes the owning operator's slug + display name and a single-resource `_meta` envelope with `scraped_at`, `source_url`, and `confidence_hint`. Call this when you want a pinnable, deep-linkable detail page for exactly one licence record.","security":[{"bearerAuth":[]}],"parameters":[{"name":"license_id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"as_of","in":"query","schema":{"type":"string"},"example":"2026-03-01","description":"Point-in-time lookup. Adds an `as_of` object reconstructing this licence's status within our observation window (the iron rule: never extrapolated before `tracking_since`). Bare YYYY-MM-DD = end of day UTC; ISO datetime supported; a future value 400s."}],"responses":{"200":{"description":"Licence + provenance.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LicenseDetail"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/ServerError"}}}},"/v1/licenses/{license_id}/history":{"get":{"operationId":"getLicenseHistory","tags":["Licences"],"summary":"Status-change history for a licence","description":"Time-ordered status changes for one licence — active → suspended → revoked, etc. Most recent first. Each row cites the scraper run that detected the change and the regulator URL at the time. Aggregate `_meta.freshness_range` surfaces the oldest + newest change in the returned window so agents can answer \"was there activity in the last 90 days?\" in one request.","security":[{"bearerAuth":[]}],"parameters":[{"name":"license_id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"History entries, most-recent first.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LicenseHistoryResponse"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/ServerError"}}}},"/v1/watchlist":{"get":{"operationId":"getWatchlist","tags":["Watchlist"],"summary":"List watched operators (recent 50) with current status","description":"Returns the caller's watched operators along with each operator's current licence status + jurisdiction. Includes `count` (total watched) and `limit` (plan cap) so a UI can render the quota bar without a second call. For full pagination use `GET /v1/watchlist/operators`.","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Watchlist summary.","content":{"application/json":{"schema":{"type":"object","required":["count","operators"],"properties":{"count":{"type":"integer"},"limit":{"type":["integer","null"]},"operators":{"type":"array","items":{"$ref":"#/components/schemas/WatchlistOperator"}}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"402":{"$ref":"#/components/responses/PaymentRequired"},"500":{"$ref":"#/components/responses/ServerError"}}}},"/v1/watchlist/operators":{"get":{"operationId":"listWatchlistOperators","tags":["Watchlist"],"summary":"Paginated list of watched operators","description":"Same shape as `GET /v1/watchlist` but supports `limit` / `offset` for clients that need to walk a larger watchlist. Sorted by `added_at` descending.","security":[{"bearerAuth":[]}],"parameters":[{"name":"limit","in":"query","schema":{"type":"integer","minimum":1,"maximum":200,"default":50}},{"name":"offset","in":"query","schema":{"type":"integer","minimum":0,"default":0}}],"responses":{"200":{"description":"Paginated watchlist.","content":{"application/json":{"schema":{"type":"object","required":["total","limit","offset","operators"],"properties":{"total":{"type":"integer"},"limit":{"type":"integer"},"offset":{"type":"integer"},"operators":{"type":"array","items":{"$ref":"#/components/schemas/WatchlistOperator"}}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"500":{"$ref":"#/components/responses/ServerError"}}},"post":{"operationId":"addWatchlistOperator","tags":["Watchlist"],"summary":"Add an operator to the watchlist","description":"Adds the operator identified by `operator_slug`. Idempotent within an account: a second add returns 409 rather than 201. Rejected with 403 `watchlist_quota_exceeded` if the caller has reached their plan ceiling (`max_watchlist_operators`).","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["operator_slug"],"properties":{"operator_slug":{"type":"string","pattern":"^[a-z0-9-]+$","minLength":1,"maxLength":100,"example":"888-uk-limited"}}}}}},"responses":{"201":{"description":"Operator added.","content":{"application/json":{"schema":{"type":"object","required":["operator","added_at"],"properties":{"operator":{"type":"object","required":["id","slug","display_name"],"properties":{"id":{"type":"string","format":"uuid"},"slug":{"type":"string"},"display_name":{"type":"string"}}},"added_at":{"type":"string","format":"date-time"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/QuotaExceeded"},"404":{"$ref":"#/components/responses/NotFound"},"409":{"$ref":"#/components/responses/Conflict"},"500":{"$ref":"#/components/responses/ServerError"}}}},"/v1/watchlist/operators/{slug}":{"delete":{"operationId":"removeWatchlistOperator","tags":["Watchlist"],"summary":"Remove an operator from the watchlist","description":"Idempotent — returns 204 whether or not the operator was actually on the list. After removal, no further events fire for the operator (subject to the per-endpoint `watchlist_only` flag).","security":[{"bearerAuth":[]}],"parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string","pattern":"^[a-z0-9-]+$","minLength":1,"maxLength":100}}],"responses":{"204":{"description":"Removed (or was never on the list)."},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"500":{"$ref":"#/components/responses/ServerError"}}}},"/v1/watchlist/events":{"get":{"operationId":"listWatchlistEvents","tags":["Watchlist"],"summary":"Polling fallback feed for watchlist events","description":"Cursor-paginated list of webhook-shape events (same envelope, minus the per-delivery signature) that would have fired for the caller's watchlist. Use as a fallback when an inbound webhook receiver is not feasible — corporate networks, agents without a public URL, backfill after webhook outages.\n\nPass `since=<ISO>` for the first call (bootstrap window, max 30 days). Subsequent calls pass the `next_cursor` from the previous response. Don't pass both.\n\n**Rate limit** — separate from the monthly quota. Per-hour ceiling per plan: Starter 10, Pro 60, Business 600, Enterprise unlimited. 429 with `details.reason=watchlist_events_poll_limit` when exhausted.\n\nEvery response carries `X-Poll-RateLimit-{Limit,Remaining,Reset,Window}` plus `X-Poll-Recommended-Interval` (seconds the SDK should sleep between polls to stay under the ceiling).","security":[{"bearerAuth":[]}],"parameters":[{"name":"since","in":"query","description":"ISO-8601 timestamp. Returns events created after this point. Window capped at 30 days (matches webhook_events retention).","schema":{"type":"string","format":"date-time"}},{"name":"cursor","in":"query","description":"Opaque base64url cursor returned from the previous response.","schema":{"type":"string","maxLength":512}},{"name":"limit","in":"query","schema":{"type":"integer","minimum":1,"maximum":500,"default":100}}],"responses":{"200":{"description":"Events page.","headers":{"X-Poll-RateLimit-Limit":{"description":"Per-hour poll ceiling for the caller's plan, or `unlimited`.","schema":{"type":"string"}},"X-Poll-RateLimit-Remaining":{"description":"Polls remaining this hour. Omitted when the limit is unlimited.","schema":{"type":"integer"}},"X-Poll-RateLimit-Reset":{"description":"ISO-8601 timestamp when the per-hour counter rolls over.","schema":{"type":"string","format":"date-time"}},"X-Poll-RateLimit-Window":{"description":"Always `hour`.","schema":{"type":"string"}},"X-Poll-Recommended-Interval":{"description":"Recommended seconds between polls — `ceil(3600 / limit)`. SDKs that sleep this long never hit the limit by construction.","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"type":"object","required":["events","has_more"],"properties":{"events":{"type":"array","items":{"$ref":"#/components/schemas/WebhookEventEnvelope"}},"next_cursor":{"type":["string","null"]},"has_more":{"type":"boolean"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"402":{"$ref":"#/components/responses/PaymentRequired"},"429":{"$ref":"#/components/responses/RateLimited"},"500":{"$ref":"#/components/responses/ServerError"}}}},"/v1/webhooks":{"get":{"operationId":"listWebhooks","tags":["Webhooks"],"summary":"List the caller's webhook endpoints","description":"Endpoints owned by the authenticated user. Secret values are NOT returned — they are revealed once at creation and on rotation.","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Endpoint list.","content":{"application/json":{"schema":{"type":"object","required":["endpoints"],"properties":{"endpoints":{"type":"array","items":{"$ref":"#/components/schemas/WebhookEndpoint"}}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"500":{"$ref":"#/components/responses/ServerError"}}},"post":{"operationId":"createWebhook","tags":["Webhooks"],"summary":"Create a webhook endpoint, reveal secret once","description":"Validates the URL synchronously against the SSRF blocklist (private IPs, loopback, link-local, cloud-metadata hostnames, IPv6 equivalents). 400 with `details.reason=private_ip_blocked` if rejected.\n\nReturns the raw signing secret ONCE in the `secret` field. Subsequent `GET /v1/webhooks` calls do not include it. Lost the secret? Rotate.\n\nPlan-quota check happens after URL validation. 403 with `details.reason=webhook_quota_exceeded` when over `max_webhook_endpoints`.","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookCreate"}}}},"responses":{"201":{"description":"Endpoint created. Note the `secret` field — visible only here.","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/WebhookEndpoint"},{"type":"object","required":["secret"],"properties":{"secret":{"type":"string","description":"Raw HMAC-SHA256 signing secret, prefix `whsec_`. Returned ONCE.","example":"whsec_...redacted..."}}}]}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/QuotaExceeded"},"500":{"$ref":"#/components/responses/ServerError"}}}},"/v1/webhooks/{id}":{"patch":{"operationId":"updateWebhook","tags":["Webhooks"],"summary":"Update an endpoint","description":"Partial update — pass only the fields you want to change. URL changes re-run the SSRF validator. `description: null` clears the description; omitting the field leaves it untouched.","security":[{"bearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookPatch"}}}},"responses":{"200":{"description":"Updated endpoint.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookEndpoint"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/ServerError"}}},"delete":{"operationId":"deleteWebhook","tags":["Webhooks"],"summary":"Delete an endpoint","description":"Removes the endpoint. ON DELETE CASCADE drops `webhook_secrets` and `webhook_deliveries` rows; pending deliveries abandon. Returns 204 on success, 404 if the id is unknown or owned by another user.","security":[{"bearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":"Deleted."},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/ServerError"}}}},"/v1/webhooks/{id}/rotate_secret":{"post":{"operationId":"rotateWebhookSecret","tags":["Webhooks"],"summary":"Issue a new signing secret with 7-day overlap","description":"Issues a new HMAC secret and stamps the currently-active one with `expires_at = NOW() + 7 days`. Deliveries during the overlap sign with **both** secrets — every signature header carries multiple `v1=` values; receivers accept if any verifies.\n\nUpdate your server to accept the new secret BEFORE the old one expires (day 7), or deliveries silently fail signature verification on your end. Subscribe to `webhook.endpoint_degraded` on a different endpoint to catch the failure.","security":[{"bearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"New secret issued.","content":{"application/json":{"schema":{"type":"object","required":["new_secret"],"properties":{"new_secret":{"type":"string","description":"Raw new secret, returned once. `whsec_` prefix."},"previous_expires_at":{"type":["string","null"],"format":"date-time","description":"When the previous secret expires. Null if there was no previous active secret."}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"500":{"$ref":"#/components/responses/ServerError"}}}},"/v1/webhooks/{id}/deliveries":{"get":{"operationId":"listWebhookDeliveries","tags":["Webhooks"],"summary":"List recent deliveries","description":"Last N deliveries on this endpoint, newest first. Use `status` to filter to `pending` / `delivered` / `failed` / `abandoned`. Retention is 7 days — older rows are pruned.","security":[{"bearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"status","in":"query","schema":{"type":"string","enum":["all","pending","delivered","failed","abandoned"],"default":"all"}},{"name":"limit","in":"query","schema":{"type":"integer","minimum":1,"maximum":200,"default":100}}],"responses":{"200":{"description":"Delivery list.","content":{"application/json":{"schema":{"type":"object","required":["deliveries"],"properties":{"deliveries":{"type":"array","items":{"$ref":"#/components/schemas/WebhookDelivery"}}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"500":{"$ref":"#/components/responses/ServerError"}}}},"/v1/webhooks/{id}/test":{"post":{"operationId":"testWebhook","tags":["Webhooks"],"summary":"Send a synthetic test.ping event","description":"Delivers a `test.ping` envelope (`livemode: false`) to the endpoint URL using the currently-active secrets. Does NOT create `webhook_events` or `webhook_deliveries` rows — ephemeral, so the customer's delivery history stays clean during wiring. Result includes the HTTP status, latency, and a truncated response body.","security":[{"bearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Test fired (regardless of HTTP status returned by the receiver).","content":{"application/json":{"schema":{"type":"object","required":["delivered","latency_ms"],"properties":{"delivered":{"type":"boolean"},"http_status":{"type":["integer","null"]},"response_body":{"type":["string","null"]},"latency_ms":{"type":"integer"},"error":{"type":["string","null"]}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"409":{"$ref":"#/components/responses/Conflict"},"500":{"$ref":"#/components/responses/ServerError"}}}}}}