{
  "openapi": "3.1.0",
  "info": {
    "title": "extractbrand API",
    "version": "v1",
    "description": "Start at https://extractbrand.dev/quickstart — sign up, mint a key, make one request.\n\nDesign-system-grade brand data for any domain: colours, typography, logos, imagery and voice, extracted from the live site and served from a shared cache.\n\nEvery shape on this page is generated from the Zod schemas the runtime validates against, so the reference cannot describe a response the API does not send.\n\n## Base URL\n\n```\nhttps://api.extractbrand.dev\n```\n\n## Authentication\n\nSend your key as a bearer token on every request:\n\n```bash\ncurl https://api.extractbrand.dev/v1/brands/stripe.com \\\n  -H \"Authorization: Bearer eb_...\"\n```\n\nKeys begin with the `eb_` prefix. Create one from your [dashboard](/dashboard) — [sign up](/sign-up) first if you have no account. Treat a key like a password: it carries the full access of the organization it belongs to. The key also determines which organization you act on — there is no `organization_id` parameter anywhere in this API.\n\n## Cache-first, with a job for the misses\n\n`GET /v1/brands/{domain}` answers from a shared cache. A domain someone has already extracted answers `200` in milliseconds. A domain nobody has extracted yet answers `202` with a job, and you re-request the same URL to collect the result — the job envelope's `poll_url` points back at it. Pass `?wait=N` to hold the request open for up to 25 seconds so a first-time domain can answer `200` in one call.\n\nAn entry lives for 30 days. Past that it is served with `cache_status: \"stale\"` while a refresh runs behind it, so you are never blocked on an expiry. `cache_status` tells you what latency to expect, never what you were charged — the charge is the same either way.\n\n## Quota\n\nEvery answered call costs one unit — a domain we already hold and a domain nobody has read yet cost the same, because the meter counts what we serve you rather than what we had to run. `POST /v1/brands/{domain}/refresh` costs more, since it always forces a fresh extraction. Every `200` and `202` carries the organization's standing in its `quota` block. A spent quota answers `429` with code `quota_exceeded`.\n\n## Errors\n\nEvery non-2xx body on every endpoint is the same shape, with a code from one closed set:\n\n```json\n{\n  \"error\": {\n    \"code\": \"invalid_domain\",\n    \"message\": \"The provided domain is invalid or could not be resolved.\"\n  }\n}\n```\n\nContract extras live inside that same `error` object, never beside it — a `429` carries `error.retry_after_seconds`, a `422` carries `error.failure_reason`. A `429` also sets the standard `Retry-After` header, from the same number."
  },
  "servers": [{ "url": "https://api.extractbrand.dev", "description": "Production" }],
  "tags": [
    {
      "name": "Brands",
      "description": "Read and refresh brand data for a domain. Brand data is derived from the public web and shared across every organization, so a domain another customer has already extracted answers instantly and for free."
    },
    { "name": "Account", "description": "Confirm which organization the calling API key acts on." },
    {
      "name": "Usage",
      "description": "What your organization has spent, and against what limit.\n\nThe one thing to understand before reading any figure here: **quota meters answered calls, not the work behind them.** A domain already held costs exactly what one extracted on demand costs, because the meter counts what we hand back rather than what it took to get. An explicit refresh costs more than a read. An organization sitting at its ceiling is refused rather than served."
    },
    {
      "name": "Billing",
      "description": "Buy a plan, manage the subscription, and cap what you can be charged.\n\nCheckout and the Billing Portal both answer with a short-lived Stripe-hosted URL rather than handling card details themselves — no payment information ever passes through this API. The portal manages an existing customer, so an organization that has never checked out has nothing to open and is told so rather than having an empty customer created for it.\n\n`PUT /v1/billing/ceiling` is the spend guard: it caps billable extractions per period and is the only endpoint here that changes what you can be charged. Zero is a legal ceiling: it does not revoke your key, but every billable call is refused with 429 until the ceiling is raised or the period rolls."
    },
    {
      "name": "Webhooks",
      "description": "Stop polling. Register an HTTPS endpoint and every extraction your organization is waiting on is delivered to it when it finishes — including one another organization triggered and you joined, which is the common case on a shared cache.\n\nThe endpoints below manage the registry. The delivery itself is an **outbound** request from us to you, which OpenAPI's operation model cannot express, so its contract is stated here.\n\n## What we POST to you\n\n```json\n{\n  \"object\": \"event\",\n  \"event_id\": \"evt_9f2c1d0b7a3e4f5061728394a5b6c7d8\",\n  \"event_type\": \"brand.extraction.completed\",\n  \"created_at\": \"2026-07-31T09:14:22.104Z\",\n  \"job_id\": \"5f2c…\",\n  \"data\": { \"object\": \"brand\", \"domain\": \"stripe.com\", \"…\": \"…\" }\n}\n```\n\n`event_type` is one of `brand.extraction.completed`, `brand.extraction.failed` or `webhook.test`. Switch on it and ignore anything you do not recognise — the list is additive.\n\n- `brand.extraction.completed` — `data` is exactly the `200` body of `GET /v1/brands/{domain}` minus its `quota` block. Hand it to the same parser.\n- `brand.extraction.failed` — `data` is `{ \"object\": \"extraction_failure\", \"domain\": …, \"failure_reason\": … }`, carrying the same closed `failure_reason` vocabulary the API serves. A suppressed domain is delivered here with reason `suppressed`, so a subscriber is never left waiting on a brand that will not arrive.\n- `webhook.test` — `data` is `{ \"object\": \"webhook_test\", \"message\": … }` and carries no brand. Only a test send produces it.\n\n## Quality is a field, never an event\n\nA `completed` delivery reports how much of the brand the extraction actually came back with, in the same two fields the `200` body carries: `quality_verdict` — `complete`, `partial` or `thin` — and `missing_fields`, the field names that read did not find. A `null` verdict means the entry predates the field and was never assessed; read it as unknown, never as `complete`, and `missing_fields` is `null` with it. Both report what we did not find, never a claim that the company does not have one.\n\nQuality and failure are two axes and never appear on one delivery: a `completed` event carries a verdict and no `failure_reason`, a `failed` event carries a `failure_reason` and no verdict. A thin extraction is therefore delivered as `brand.extraction.completed` with a `thin` verdict — there is no degraded event type, and none is coming. Alert on the field.\n\n## Verifying the signature\n\nEvery delivery carries `x-extractbrand-signature: t=<unix seconds>,v1=<hex>`. The digest is `HMAC-SHA256(secret, \"<t>.<raw request body>\")`, hex-encoded. Verify against the **raw** bytes, never a re-serialization of the parsed JSON, and compare in constant time. Reject a delivery whose `t` is more than 300 seconds from your clock — the timestamp is inside the signed material precisely so that check is yours to make.\n\nOther headers: `x-extractbrand-event-id`, `x-extractbrand-event-type` and `x-extractbrand-delivery-attempt`. All three are also inside the signed body.\n\n## Delivery and retries\n\nAt-least-once. Answer `2xx` as soon as you have durably accepted the event and do your work afterwards; anything else is a failure. A `429` or `5xx` is retried up to five attempts total with 30s / 2m / 8m / 32m backoff, and every other `4xx` is not retried at all — those describe the request, and repeating it changes nothing. Redirects are never followed. **Deduplicate on `event_id`**: it is stable across every retry of one delivery and distinct for every endpoint.\n\nA slow or failing receiver never affects the extraction itself, and deliveries are never metered."
    }
  ],
  "components": {
    "securitySchemes": {
      "BearerAuth": {
        "type": "http",
        "scheme": "bearer",
        "description": "Your API key, sent as `Authorization: Bearer eb_...`. Create and revoke keys from your [dashboard](/dashboard)."
      }
    },
    "schemas": {
      "ActiveJobStatusSchema": { "type": "string", "enum": ["queued", "running"] },
      "ApiError": {
        "type": "object",
        "properties": {
          "error": {
            "type": "object",
            "properties": {
              "code": { "$ref": "#/components/schemas/ApiErrorCode" },
              "message": {
                "type": "string",
                "description": "A human-readable sentence describing the failure."
              }
            },
            "required": ["code", "message"]
          }
        },
        "required": ["error"]
      },
      "ApiErrorCode": {
        "type": "string",
        "enum": [
          "invalid_domain",
          "invalid_parameter",
          "invalid_api_key",
          "rate_limited",
          "refresh_rate_limited",
          "quota_exceeded",
          "extraction_failed",
          "service_unavailable",
          "removed",
          "internal_error",
          "not_found",
          "invalid_webhook_url",
          "webhook_limit_reached",
          "webhook_url_registered",
          "brand_claimed",
          "refresh_not_included"
        ]
      },
      "BillingError": {
        "type": "object",
        "properties": {
          "error": {
            "type": "object",
            "properties": {
              "code": { "$ref": "#/components/schemas/BillingErrorCode" },
              "message": {
                "type": "string",
                "description": "A human-readable sentence describing the answer."
              }
            },
            "required": ["code", "message"]
          }
        },
        "required": ["error"]
      },
      "BillingErrorCode": {
        "type": "string",
        "enum": ["unknown_plan", "plan_unavailable", "no_billing_account", "invalid_ceiling"]
      },
      "Brand": {
        "type": "object",
        "properties": {
          "name": { "type": ["string", "null"] },
          "tagline": { "type": ["string", "null"] },
          "description": { "type": ["string", "null"] },
          "industry": { "type": ["string", "null"] },
          "audience": { "type": ["string", "null"] },
          "positioning_statement": {
            "type": ["string", "null"],
            "description": "Why this company exists, who it serves, and what makes it different."
          },
          "values": { "type": "array", "items": { "type": "string" } },
          "colors": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "hex": { "type": "string", "description": "The colour as `#rrggbb`, lowercase." },
                "role": {
                  "type": ["string", "null"],
                  "enum": ["primary", "secondary", "accent", "background", "surface", "text"],
                  "description": "What the colour is for, or `null` for a colour the page paints that fills no role. The order of this list is the rank order."
                },
                "on": {
                  "type": "string",
                  "description": "A text colour that is legible on `hex`, so a caller never derives one."
                },
                "names": {
                  "type": "array",
                  "items": { "type": "string" },
                  "description": "The names the site's own stylesheet gives this colour — its CSS custom properties. Empty when the site names it nothing."
                },
                "coverage": {
                  "type": ["number", "null"],
                  "description": "Fraction of the rendered page this colour paints, 0 to 1. Null when the page could not be rendered or the entry predates this measurement."
                }
              },
              "required": ["hex", "role", "on", "names", "coverage"]
            },
            "description": "Every colour of the brand, as one ranked list, deduplicated by hex. Read `colors[0]` for the dominant colour, or find the entry whose `role` you want — match on `role` rather than assuming a position, because a brand missing a role shifts every entry below it up. The entries with a non-null `role` are the brand's paint kit, in rank order; the entries with `role: null` are colours the page genuinely paints that fill no role, ranked after them by painted area. A colour holds one role only: a brand whose body ink is also its primary reports that hex once, under the higher-ranked role."
          },
          "fonts": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "family": {
                  "type": "string",
                  "description": "The family name as the site declares it."
                },
                "role": {
                  "type": "string",
                  "enum": ["display", "body"],
                  "description": "Where the face is used. The first family is the display face."
                },
                "availability": {
                  "type": "string",
                  "enum": ["google", "custom-webfont", "system", "unknown"],
                  "description": "How the face can be loaded. `unknown` when the site gave no signal."
                }
              },
              "required": ["family", "role", "availability"]
            },
            "description": "The brand's faces, most prominent first, at most two. Replaces the former array of family names and the `typography` block: `family` is what that array carried, `role` is the heading/body split and `availability` is how to load the face."
          },
          "font_confidence": {
            "type": "number",
            "description": "How firmly the families above were resolved, 0 to 1."
          },
          "logo": { "$ref": "#/components/schemas/BrandLogo" },
          "visual_style": { "$ref": "#/components/schemas/BrandVisualStyle" },
          "motion": {
            "anyOf": [{ "$ref": "#/components/schemas/BrandMotion" }, { "type": "null" }],
            "description": "Null when the site carries no motion evidence."
          },
          "spacing": {
            "type": "object",
            "properties": {
              "base_unit": { "type": "number", "description": "The base spacing unit in pixels." },
              "scale": {
                "type": "array",
                "items": { "type": "number" },
                "description": "The scale, as multipliers of `base_unit`."
              },
              "content_margin": {
                "type": "number",
                "description": "The outer horizontal content margin in pixels."
              }
            },
            "required": ["base_unit", "scale", "content_margin"],
            "description": "The spacing system read off the brand's own CSS."
          },
          "accessibility": {
            "type": "object",
            "properties": {
              "contrast_target": { "type": "string", "enum": ["AA", "AAA"] },
              "min_font_size_px": { "type": "number" },
              "colorblind_safe": { "type": "boolean" }
            },
            "required": ["contrast_target", "min_font_size_px", "colorblind_safe"]
          },
          "tone": { "type": "array", "items": { "type": "string" } },
          "voice": { "type": "array", "items": { "type": "string" } },
          "copywriting": {
            "type": "object",
            "properties": {
              "preferred_terms": {
                "type": "array",
                "items": { "type": "string" },
                "description": "Words and phrases the brand actively uses."
              },
              "avoided_terms": {
                "type": "array",
                "items": { "type": "string" },
                "description": "Words and phrases that conflict with the voice."
              },
              "cta_style": {
                "type": "string",
                "description": "How calls to action are written on this brand."
              },
              "headline_case": {
                "type": "string",
                "description": "Common values: title, sentence, upper, mixed."
              },
              "tone_by_context": {
                "type": "array",
                "items": {
                  "type": "object",
                  "properties": { "context": { "type": "string" }, "tone": { "type": "string" } },
                  "required": ["context", "tone"]
                },
                "description": "How the voice shifts across surfaces. Empty when the tone is uniform."
              }
            },
            "required": [
              "preferred_terms",
              "avoided_terms",
              "cta_style",
              "headline_case",
              "tone_by_context"
            ]
          },
          "brand_intelligence": {
            "type": ["object", "null"],
            "properties": {
              "company_summary": { "type": "string" },
              "known_for": { "type": "array", "items": { "type": "string" } },
              "design_personality": { "type": "string" },
              "design_conventions": { "type": "array", "items": { "type": "string" } },
              "slide_guidance": { "type": "string" },
              "brand_values": { "type": "array", "items": { "type": "string" } },
              "inferred_audience": { "type": "string" },
              "writing_style": { "type": "string" }
            },
            "required": [
              "company_summary",
              "known_for",
              "design_personality",
              "design_conventions",
              "slide_guidance",
              "brand_values",
              "inferred_audience",
              "writing_style"
            ],
            "description": "A deeper read of the company behind the brand. Null when the site gave too little to work from."
          },
          "confidence": {
            "type": "number",
            "description": "Overall extraction confidence, 0 to 1. Low values are reported, never hidden."
          },
          "confidence_per_field": {
            "type": "object",
            "additionalProperties": { "type": "number" },
            "propertyNames": { "type": "string" },
            "description": "Per-field confidence, keyed by extracted field name — `primary`, `fonts`, `positioning_statement` and the like. A field the extraction never reached is absent rather than zero."
          },
          "confidence_signals": {
            "type": "array",
            "items": { "type": "string" },
            "description": "What the confidence score was built from."
          },
          "gaps": {
            "type": "array",
            "items": { "type": "string" },
            "description": "Fields no specialist could fill. Empty means the extraction was complete."
          },
          "platform": {
            "type": "string",
            "enum": [
              "NextJsVite",
              "WordPress",
              "Framer",
              "Webflow",
              "Wix",
              "Shopify",
              "Generic",
              "CssInJs",
              "Astro",
              "Squarespace",
              "Ghost",
              "EditorX",
              "HubSpotCms",
              "BigCommerce",
              "Mantine",
              "Tailwind",
              "In5",
              "GitHub",
              "Webstudio",
              "Remix",
              "VercelStaticExport"
            ],
            "description": "The site platform the page was recognised as."
          },
          "screenshot_blob_url": {
            "type": ["string", "null"],
            "description": "The full-page screenshot the brand was read from. Null when the page was read without rendering."
          },
          "schema_version": {
            "type": "string",
            "description": "The payload version. Bumped only when a field changes meaning."
          }
        },
        "required": [
          "name",
          "tagline",
          "description",
          "industry",
          "audience",
          "positioning_statement",
          "values",
          "colors",
          "fonts",
          "font_confidence",
          "logo",
          "visual_style",
          "motion",
          "spacing",
          "accessibility",
          "tone",
          "voice",
          "copywriting",
          "brand_intelligence",
          "confidence",
          "confidence_per_field",
          "confidence_signals",
          "gaps",
          "platform",
          "screenshot_blob_url",
          "schema_version"
        ]
      },
      "BrandLogo": {
        "type": "object",
        "properties": {
          "url": {
            "type": ["string", "null"],
            "description": "The primary mark, at the URL the site itself serves it from."
          },
          "svg": {
            "type": ["string", "null"],
            "description": "The mark as a sanitized SVG document, when the site publishes it inline rather than as a file — the only form of the mark when `url` is null and `source` is inline-svg. Render it as an image (for example via a data: URI), never as live markup."
          },
          "on_dark_url": {
            "type": ["string", "null"],
            "description": "The variant to place on a dark background, when the site publishes a distinct one."
          },
          "on_light_url": {
            "type": ["string", "null"],
            "description": "The variant to place on a light background, when the site publishes a distinct one."
          },
          "source": {
            "type": ["string", "null"],
            "description": "Where the mark was found. Common values: inline-svg, header-img, json-ld, apple-touch-icon, og-image, manifest, favicon."
          },
          "description": {
            "type": ["string", "null"],
            "description": "One or two sentences on the mark's form and what it depicts, read from the rendered page."
          },
          "dominant_colors": {
            "type": "array",
            "items": { "type": "string" },
            "description": "Hex colours sampled from the mark itself."
          },
          "luminance": {
            "type": ["number", "null"],
            "description": "Perceived lightness of the mark, 0 (black) to 1 (white)."
          },
          "has_transparency": {
            "type": "boolean",
            "description": "Whether the mark carries an alpha channel."
          },
          "contrast_profile": {
            "type": ["string", "null"],
            "enum": ["light-dominant", "dark-dominant", "mixed", "colorful"],
            "description": "Which backgrounds the mark reads against."
          },
          "background_color": {
            "type": ["string", "null"],
            "description": "The colour the mark is drawn on where it appears baked into a plate."
          }
        },
        "required": [
          "url",
          "svg",
          "on_dark_url",
          "on_light_url",
          "source",
          "description",
          "dominant_colors",
          "luminance",
          "has_transparency",
          "contrast_profile",
          "background_color"
        ]
      },
      "BrandMotion": {
        "type": "object",
        "properties": {
          "personality": {
            "type": ["string", "null"],
            "description": "One sentence on how the brand visibly animates."
          },
          "intensity": {
            "type": ["string", "null"],
            "description": "Common values: none, minimal, subtle, moderate, rich. Use a richer phrase when it better captures the brand."
          },
          "speed": {
            "type": ["string", "null"],
            "description": "Common values: instant, fast, moderate, slow. Use a richer phrase when it better captures the brand."
          },
          "easing_style": {
            "type": ["string", "null"],
            "description": "The character of the observed timing functions."
          },
          "signature_effects": {
            "type": "array",
            "items": { "type": "string" },
            "description": "Recognisable recurring effects, by element and behaviour."
          },
          "detected_libraries": { "type": "array", "items": { "type": "string" } },
          "has_scroll_animations": { "type": ["boolean", "null"] },
          "has_hover_effects": { "type": ["boolean", "null"] },
          "reduced_motion_support": { "type": ["boolean", "null"] }
        },
        "required": [
          "personality",
          "intensity",
          "speed",
          "easing_style",
          "signature_effects",
          "detected_libraries",
          "has_scroll_animations",
          "has_hover_effects",
          "reduced_motion_support"
        ]
      },
      "BrandProvenance": {
        "type": "object",
        "properties": {
          "source": {
            "type": "string",
            "enum": ["measured", "owner"],
            "description": "Whether any field in this response was authored by the brand's owner. 'owner' when owner_fields is non-empty, 'measured' otherwise."
          },
          "owner_fields": {
            "type": "array",
            "items": { "type": "string" },
            "description": "Dotted paths into this response that the brand's verified owner authored. Every field not listed was measured from the brand's own site."
          }
        },
        "required": ["source", "owner_fields"]
      },
      "BrandResponse": {
        "type": "object",
        "properties": {
          "object": { "type": "string", "enum": ["brand"] },
          "domain": { "type": "string" },
          "cache_status": { "$ref": "#/components/schemas/ServedCacheStatusSchema" },
          "extracted_at": { "type": "string" },
          "expires_at": { "type": "string" },
          "quality_verdict": {
            "anyOf": [{ "$ref": "#/components/schemas/QualityVerdictSchema" }, { "type": "null" }]
          },
          "missing_fields": { "type": ["array", "null"], "items": { "type": "string" } },
          "brand": { "$ref": "#/components/schemas/Brand" },
          "provenance": { "$ref": "#/components/schemas/BrandProvenance" },
          "quota": { "$ref": "#/components/schemas/Quota" }
        },
        "required": [
          "object",
          "domain",
          "cache_status",
          "extracted_at",
          "expires_at",
          "quality_verdict",
          "missing_fields",
          "brand",
          "provenance",
          "quota"
        ]
      },
      "BrandVisualStyle": {
        "type": "object",
        "properties": {
          "theme": { "type": ["string", "null"], "enum": ["light", "dark"] },
          "corner_style": {
            "type": ["string", "null"],
            "description": "Common values: sharp, subtle, rounded, pill. Use a richer phrase when it better captures the brand."
          },
          "headline_transform": {
            "type": ["string", "null"],
            "description": "Common values: uppercase, capitalize, none. Use a richer phrase when it better captures the brand."
          },
          "headline_tracking": {
            "type": ["string", "null"],
            "description": "Common values: tight, normal, wide. Use a richer phrase when it better captures the brand."
          },
          "headline_weight": {
            "type": ["string", "null"],
            "description": "Common values: light, normal, medium, bold, heavy. Use a richer phrase when it better captures the brand."
          },
          "shadow_style": {
            "type": ["string", "null"],
            "description": "Common values: none, subtle, elevated, dramatic. Use a richer phrase when it better captures the brand."
          },
          "uses_gradients": { "type": ["boolean", "null"] },
          "border_style": {
            "type": ["string", "null"],
            "description": "Common values: none, subtle, prominent. Use a richer phrase when it better captures the brand."
          },
          "photography_treatment": {
            "type": ["string", "null"],
            "description": "Common values: full-color, muted, grayscale, duotone, high-contrast. Use a richer phrase when it better captures the brand."
          },
          "density": {
            "type": ["string", "null"],
            "description": "Common values: dense, balanced, spacious. Use a richer phrase when it better captures the brand."
          },
          "aesthetic_tags": { "type": "array", "items": { "type": "string" } },
          "headline_letter_spacing": {
            "type": ["string", "null"],
            "description": "The authored CSS value, e.g. `-0.02em`."
          },
          "headline_font_weight": { "type": ["number", "null"] },
          "border_radius": {
            "type": ["string", "null"],
            "description": "The authored CSS value, e.g. `12px`."
          },
          "box_shadow": { "type": ["string", "null"], "description": "The authored CSS value." },
          "icon_style": {
            "type": ["string", "null"],
            "description": "Common values: outlined, filled, duotone, hand-drawn. Use a richer phrase when it better captures the brand."
          },
          "illustration_style": {
            "type": ["string", "null"],
            "description": "Common values: geometric, organic, flat, 3d, isometric, hand-drawn, line-art, none. Use a richer phrase when it better captures the brand."
          },
          "photography_subjects": {
            "type": ["string", "null"],
            "description": "What the brand's imagery actually shows."
          },
          "photography_composition": {
            "type": ["string", "null"],
            "description": "How that imagery is composed and cropped."
          },
          "photography_color_grading": {
            "type": ["string", "null"],
            "description": "The colour treatment applied to it."
          },
          "patterns": {
            "type": "array",
            "items": { "type": "string" },
            "description": "Recurring graphic motifs, e.g. faint grid lines."
          }
        },
        "required": [
          "theme",
          "corner_style",
          "headline_transform",
          "headline_tracking",
          "headline_weight",
          "shadow_style",
          "uses_gradients",
          "border_style",
          "photography_treatment",
          "density",
          "aesthetic_tags",
          "headline_letter_spacing",
          "headline_font_weight",
          "border_radius",
          "box_shadow",
          "icon_style",
          "illustration_style",
          "photography_subjects",
          "photography_composition",
          "photography_color_grading",
          "patterns"
        ]
      },
      "Ceiling": {
        "type": "object",
        "properties": {
          "ceiling": {
            "type": "number",
            "description": "The limit now in force, after clamping against the plan allowance."
          },
          "ceiling_source": {
            "type": "string",
            "enum": ["plan", "org"],
            "description": "Which of the two set the figure above."
          },
          "clamped": {
            "type": "boolean",
            "description": "True when the stored value was higher than the plan allows and the plan's number is what is being enforced. The value you sent is still what is stored."
          }
        },
        "required": ["ceiling", "ceiling_source", "clamped"]
      },
      "CeilingRequest": {
        "type": "object",
        "properties": {
          "monthly_ceiling": {
            "type": ["number", "null"],
            "description": "A whole number of extractions per period, or null to clear the ceiling and fall back to the plan's allowance. Zero is legal and means no fresh extractions this period. The value you send is what gets stored — the clamp against the plan allowance is applied on read, so raising your plan later restores the ceiling you actually asked for.",
            "examples": [500]
          }
        },
        "required": ["monthly_ceiling"]
      },
      "CheckoutRequest": {
        "type": "object",
        "properties": {
          "plan": {
            "type": "string",
            "enum": ["free", "starter"],
            "description": "The plan to purchase.",
            "examples": ["starter"]
          }
        },
        "required": ["plan"]
      },
      "CheckoutSession": {
        "type": "object",
        "properties": {
          "checkout_url": {
            "type": "string",
            "description": "A Stripe-hosted Checkout URL. Redirect the customer to it; it is single-use and expires."
          }
        },
        "required": ["checkout_url"]
      },
      "ExtractionFailedError": {
        "type": "object",
        "properties": {
          "error": {
            "type": "object",
            "properties": {
              "code": { "$ref": "#/components/schemas/ApiErrorCode" },
              "message": {
                "type": "string",
                "description": "A human-readable sentence describing the failure."
              },
              "failure_reason": {
                "allOf": [{ "$ref": "#/components/schemas/FailureReasonSchema" }],
                "description": "Why the most recent extraction of this domain failed."
              }
            },
            "required": ["code", "message"]
          }
        },
        "required": ["error"]
      },
      "FailureReasonSchema": {
        "type": "string",
        "enum": [
          "bot_blocked",
          "unreachable",
          "timeout",
          "extraction_failed",
          "enqueue_failed",
          "stale_claim_superseded",
          "suppressed",
          "claimed"
        ]
      },
      "JobResponse": {
        "type": "object",
        "properties": {
          "object": { "type": "string", "enum": ["job"] },
          "job_id": { "type": "string" },
          "domain": { "type": "string" },
          "status": { "$ref": "#/components/schemas/ActiveJobStatusSchema" },
          "poll_url": { "type": "string" },
          "quota": { "$ref": "#/components/schemas/Quota" }
        },
        "required": ["object", "job_id", "domain", "status", "poll_url", "quota"]
      },
      "MeResponse": {
        "type": "object",
        "properties": {
          "organization_id": {
            "type": "string",
            "description": "The organization the API key belongs to."
          },
          "organization_name": { "type": "string" },
          "key_label": {
            "type": ["string", "null"],
            "description": "The label given to the key when it was created, or null if it has none."
          }
        },
        "required": ["organization_id", "organization_name", "key_label"]
      },
      "PortalSession": {
        "type": "object",
        "properties": {
          "portal_url": {
            "type": "string",
            "description": "A Stripe-hosted Billing Portal URL for the organization's existing customer record. Single-use and expiring."
          }
        },
        "required": ["portal_url"]
      },
      "QualityVerdictSchema": { "type": "string", "enum": ["complete", "partial", "thin"] },
      "Quota": {
        "type": "object",
        "properties": {
          "used": { "type": "number" },
          "limit": { "type": "number" },
          "period_end": { "type": "string" },
          "near_limit": { "type": "boolean" }
        },
        "required": ["used", "limit", "period_end", "near_limit"]
      },
      "RateLimitedError": {
        "type": "object",
        "properties": {
          "error": {
            "type": "object",
            "properties": {
              "code": { "$ref": "#/components/schemas/ApiErrorCode" },
              "message": {
                "type": "string",
                "description": "A human-readable sentence describing the failure."
              },
              "retry_after_seconds": {
                "type": "number",
                "description": "Whole seconds to wait before retrying. Mirrors the Retry-After header."
              }
            },
            "required": ["code", "message"]
          }
        },
        "required": ["error"]
      },
      "ServedCacheStatusSchema": { "type": "string", "enum": ["hit", "stale"] },
      "UsagePeriod": {
        "type": "object",
        "properties": {
          "key": {
            "type": "string",
            "description": "The period's own key.",
            "examples": ["2026-07"]
          },
          "start": { "type": "string", "description": "ISO-8601 UTC. Inclusive." },
          "end": { "type": "string", "description": "ISO-8601 UTC. Exclusive." }
        },
        "required": ["key", "start", "end"]
      },
      "UsageStanding": {
        "type": "object",
        "properties": {
          "organization_id": {
            "type": "string",
            "description": "The organization the calling key acts on. There is no organization parameter anywhere in this API — the key decides the tenant."
          },
          "period": { "$ref": "#/components/schemas/UsagePeriod" },
          "plan": {
            "type": "string",
            "enum": ["free", "starter"],
            "description": "The plan this organization is currently on."
          },
          "fresh_reads_used": {
            "type": "number",
            "description": "Extractions debited this period. Cached reads are never counted."
          },
          "fresh_reads_limit": {
            "type": "number",
            "description": "The effective allowance — the same number `ceiling` reports."
          },
          "fresh_reads_remaining": {
            "type": "number",
            "description": "`fresh_reads_limit` minus `fresh_reads_used`, floored at zero."
          },
          "ceiling": {
            "type": "number",
            "description": "The limit actually enforced on extraction. Identical to `fresh_reads_limit`; both are reported so a client need not infer that they are the same figure."
          },
          "ceiling_source": {
            "type": "string",
            "enum": ["plan", "org"],
            "description": "`plan` when the tier's own allowance is binding, `org` when this organization set a lower ceiling of its own via `PUT /v1/billing/ceiling`."
          }
        },
        "required": [
          "organization_id",
          "period",
          "plan",
          "fresh_reads_used",
          "fresh_reads_limit",
          "fresh_reads_remaining",
          "ceiling",
          "ceiling_source"
        ]
      },
      "WebhookEndpoint": {
        "type": "object",
        "properties": {
          "object": { "type": "string", "enum": ["webhook_endpoint"] },
          "endpoint_id": { "type": "string" },
          "url": { "type": "string" },
          "status": { "type": "string", "enum": ["active", "disabled"] },
          "description": { "type": "string" },
          "created_at": { "type": "string", "description": "ISO-8601 UTC." },
          "disabled_at": {
            "type": "string",
            "description": "ISO-8601 UTC. Present only while the endpoint is disabled."
          },
          "last_attempt": { "allOf": [{ "$ref": "#/components/schemas/WebhookLastAttempt" }] }
        },
        "required": ["object", "endpoint_id", "url", "status", "created_at"]
      },
      "WebhookEndpointCreated": {
        "type": "object",
        "properties": {
          "object": { "type": "string", "enum": ["webhook_endpoint"] },
          "endpoint_id": { "type": "string" },
          "url": { "type": "string" },
          "status": { "type": "string", "enum": ["active", "disabled"] },
          "description": { "type": "string" },
          "created_at": { "type": "string", "description": "ISO-8601 UTC." },
          "signing_secret": {
            "type": "string",
            "description": "The HMAC-SHA256 key this endpoint's deliveries are signed with. Returned exactly once — here — and never by any later read.",
            "examples": ["whsec_0a1b2c..."]
          }
        },
        "required": ["object", "endpoint_id", "url", "status", "created_at", "signing_secret"]
      },
      "WebhookEndpointList": {
        "type": "object",
        "properties": {
          "object": { "type": "string", "enum": ["list"] },
          "data": { "type": "array", "items": { "$ref": "#/components/schemas/WebhookEndpoint" } }
        },
        "required": ["object", "data"]
      },
      "WebhookLastAttempt": {
        "type": "object",
        "properties": {
          "outcome": { "type": "string", "enum": ["delivered", "failed"] },
          "attempt": {
            "type": "number",
            "description": "Which attempt of that delivery this was, 1-based."
          },
          "response_status": {
            "type": ["number", "null"],
            "description": "The receiver's HTTP status, or null when nothing answered."
          },
          "error_kind": {
            "type": "string",
            "enum": ["http_status", "network", "timeout", "blocked_url"],
            "description": "Why the attempt failed. Absent on a delivered attempt."
          },
          "duration_ms": { "type": "number" },
          "occurred_at": { "type": "string", "description": "ISO-8601 UTC." }
        },
        "required": ["outcome", "attempt", "response_status", "duration_ms", "occurred_at"]
      },
      "WebhookRegisterRequest": {
        "type": "object",
        "properties": {
          "url": {
            "type": "string",
            "description": "The HTTPS URL deliveries are POSTed to. It is validated against the platform's SSRF guard both now and immediately before every delivery, so a host that later resolves to a private address stops being delivered to.",
            "examples": ["https://hooks.example.com/extractbrand"]
          },
          "description": {
            "type": "string",
            "description": "Your own label for this endpoint. Never interpreted.",
            "examples": ["production ingest"]
          }
        },
        "required": ["url"]
      },
      "WebhookTestResult": {
        "type": "object",
        "properties": {
          "object": { "type": "string", "enum": ["webhook_test"] },
          "endpoint_id": { "type": "string" },
          "event_id": {
            "type": "string",
            "description": "The `event_id` inside the payload that was sent.",
            "examples": ["evt_9f2c1d0b7a3e4f5061728394a5b6c7d8"]
          },
          "delivered": {
            "type": "boolean",
            "description": "True when the receiver answered 2xx. A test send is never retried."
          },
          "response_status": {
            "type": ["number", "null"],
            "description": "The receiver's HTTP status, or null when nothing answered."
          },
          "error_kind": {
            "type": "string",
            "enum": ["http_status", "network", "timeout", "blocked_url"],
            "description": "Why it failed. Absent when it was delivered."
          },
          "duration_ms": { "type": "number" }
        },
        "required": [
          "object",
          "endpoint_id",
          "event_id",
          "delivered",
          "response_status",
          "duration_ms"
        ]
      },
      "WebhookUrlRejectedError": {
        "type": "object",
        "properties": {
          "error": {
            "type": "object",
            "properties": {
              "code": { "$ref": "#/components/schemas/ApiErrorCode" },
              "message": {
                "type": "string",
                "description": "A human-readable sentence describing the failure."
              },
              "reason": {
                "type": "string",
                "enum": [
                  "invalid_url",
                  "insecure_scheme",
                  "blocked_host",
                  "unsupported_url_component"
                ],
                "description": "Which rule the submitted URL broke."
              }
            },
            "required": ["code", "message", "reason"]
          }
        },
        "required": ["error"]
      }
    }
  },
  "security": [{ "BearerAuth": [] }],
  "paths": {
    "/v1/me": {
      "get": {
        "operationId": "getMe",
        "summary": "Identify the calling key",
        "description": "Returns the organization the presented API key belongs to. Use it to confirm a key is live and to see which organization it acts on — there is no `organization_id` parameter anywhere in this API, the key determines the tenant.",
        "tags": ["Account"],
        "parameters": [],
        "security": [{ "BearerAuth": [] }],
        "responses": {
          "200": {
            "description": "The organization the key belongs to.",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/MeResponse" } }
            },
            "headers": {
              "X-RateLimit-Limit": {
                "description": "Requests allowed in the key's window.",
                "schema": { "type": "integer" }
              },
              "X-RateLimit-Remaining": {
                "description": "Requests left in the key's current window.",
                "schema": { "type": "integer" }
              },
              "X-RateLimit-Reset": {
                "description": "Unix time in seconds at which the key's window resets.",
                "schema": { "type": "integer" }
              }
            }
          },
          "401": {
            "description": "`invalid_api_key` — the Authorization header is missing or malformed or names a key that does not verify.",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } }
            }
          },
          "429": {
            "description": "`rate_limited` — the key exceeded its request window.",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/RateLimitedError" } }
            },
            "headers": {
              "Retry-After": {
                "description": "Whole seconds to wait before retrying.",
                "schema": { "type": "integer" }
              }
            }
          },
          "503": {
            "description": "`service_unavailable` — the key verified but the organization could not be read. Retry rather than rotate the key.",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } }
            }
          }
        }
      }
    },
    "/v1/billing/ceiling": {
      "put": {
        "operationId": "setBillingCeiling",
        "summary": "Set this organization's spend ceiling",
        "description": "Caps how many units this organization can be billed for in a period. Send a whole number to set the cap, or null to clear it and fall back to the plan's own allowance. Zero is legal and means no billable calls this period: every read is refused with 429 until the cap is raised or the period rolls. What you send is what gets stored: the clamp against the plan allowance is applied on read, so an organization that raises its plan later gets the ceiling it actually asked for rather than one truncated to the tier it was on at the time. There is no organization parameter: the key decides whose ceiling moves.",
        "tags": ["Billing"],
        "parameters": [],
        "security": [{ "BearerAuth": [] }],
        "requestBody": {
          "content": {
            "application/json": { "schema": { "$ref": "#/components/schemas/CeilingRequest" } }
          }
        },
        "responses": {
          "200": {
            "description": "The ceiling now in force. `clamped` says whether the plan allowance is what is actually binding.",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/Ceiling" } }
            }
          },
          "400": {
            "description": "`invalid_ceiling` — the body named no `monthly_ceiling` or carried something other than null or a whole number of zero or greater. Nothing was written.",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/BillingError" } }
            }
          },
          "401": {
            "description": "`invalid_api_key` — the Authorization header is missing or malformed or names a key that does not verify.",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } }
            }
          },
          "429": {
            "description": "`rate_limited` — the key exceeded its request window.",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/RateLimitedError" } }
            }
          },
          "503": {
            "description": "`service_unavailable` — the request authenticated but the ceiling could not be written. Retry rather than rotate the key.",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } }
            }
          }
        }
      }
    },
    "/v1/billing/checkout": {
      "post": {
        "operationId": "createCheckoutSession",
        "summary": "Start a checkout for a plan",
        "description": "Opens a Stripe-hosted Checkout session for this organization and returns its URL. The organization's Stripe customer is created on first use and reused thereafter. The plan must be one this API can currently sell — a tier whose price does not yet exist answers 503 `plan_unavailable` before any Stripe object is created. There is no organization parameter: the key decides who is billed. The session is presented in GBP for callers in the UK and EUR for callers in the euro area, and in USD otherwise; the currency is resolved from the calling connection's country and is not a request parameter. Anywhere outside those three, Stripe presents the USD amount converted to the buyer's local currency on the hosted page.",
        "tags": ["Billing"],
        "parameters": [],
        "security": [{ "BearerAuth": [] }],
        "requestBody": {
          "content": {
            "application/json": { "schema": { "$ref": "#/components/schemas/CheckoutRequest" } }
          }
        },
        "responses": {
          "200": {
            "description": "Redirect the customer to `checkout_url`.",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/CheckoutSession" } }
            }
          },
          "400": {
            "description": "`unknown_plan` — the body carried no readable `plan` or named a tier that does not exist.",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/BillingError" } }
            }
          },
          "401": {
            "description": "`invalid_api_key` — the Authorization header is missing or malformed or names a key that does not verify.",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } }
            }
          },
          "429": {
            "description": "`rate_limited` — the key exceeded its request window.",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/RateLimitedError" } }
            }
          },
          "503": {
            "description": "`service_unavailable` — the request authenticated but the checkout could not be created. Retry rather than rotate the key.",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } }
            }
          }
        }
      }
    },
    "/v1/billing/portal": {
      "post": {
        "operationId": "createPortalSession",
        "summary": "Open the billing portal",
        "description": "Returns a Stripe-hosted Billing Portal URL for this organization's existing customer record, where a subscription can be changed, a payment method updated or an invoice read. An organization that has never checked out has no customer to manage and answers 409 rather than having one silently created for it — start a checkout first. There is no organization parameter: the key decides whose portal opens.",
        "tags": ["Billing"],
        "parameters": [],
        "security": [{ "BearerAuth": [] }],
        "responses": {
          "200": {
            "description": "Redirect the customer to `portal_url`.",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/PortalSession" } }
            }
          },
          "401": {
            "description": "`invalid_api_key` — the Authorization header is missing or malformed or names a key that does not verify.",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } }
            }
          },
          "409": {
            "description": "`no_billing_account` — this organization has never bought anything and has no Stripe customer to manage.",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/BillingError" } }
            }
          },
          "429": {
            "description": "`rate_limited` — the key exceeded its request window.",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/RateLimitedError" } }
            }
          },
          "503": {
            "description": "`service_unavailable` — the request authenticated but the portal session could not be created. Retry rather than rotate the key.",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } }
            }
          }
        }
      }
    },
    "/v1/brands/{domain}": {
      "get": {
        "operationId": "getBrand",
        "summary": "Get a brand",
        "description": "Returns the cached brand for a domain. A fresh or stale cache entry answers `200` immediately. A domain nobody has extracted yet answers `202` with a job, and the caller re-requests this same URL to collect the result — `poll_url` in the job envelope points back here. Add `?wait=N` to hold the request open for up to N seconds so a first-time domain can answer `200` in a single call. A domain whose owner has proved control of it is served from its record and is never revalidated, so it reports `cache_status` `stale` indefinitely once its entry passes its lifetime; the record is maintained by its owner rather than by measurement, and `provenance` reports which fields they authored.",
        "tags": ["Brands"],
        "parameters": [
          {
            "in": "query",
            "name": "wait",
            "required": false,
            "schema": {
              "type": "number",
              "description": "Seconds to hold the request open waiting for an in-flight extraction to finish, so a first-time domain can answer 200 in one call. Clamped to 25; a value that is not a whole number of at least 1 is rejected with 400. Omit it for an immediate 202."
            },
            "description": "Seconds to hold the request open waiting for an in-flight extraction to finish, so a first-time domain can answer 200 in one call. Clamped to 25; a value that is not a whole number of at least 1 is rejected with 400. Omit it for an immediate 202.",
            "example": 10
          },
          {
            "in": "path",
            "name": "domain",
            "required": true,
            "schema": {
              "type": "string",
              "description": "The domain to look up, without a scheme. A host that redirects elsewhere is resolved to its landing host, and that host is the cache key the answer is keyed on."
            },
            "description": "The domain to look up, without a scheme. A host that redirects elsewhere is resolved to its landing host, and that host is the cache key the answer is keyed on.",
            "example": "stripe.com"
          }
        ],
        "security": [{ "BearerAuth": [] }],
        "responses": {
          "200": {
            "description": "The cached brand. `cache_status` is `hit` within the entry's 30-day lifetime and `stale` past it — a stale entry is served immediately while a refresh runs behind it.",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/BrandResponse" } }
            }
          },
          "202": {
            "description": "Extraction is under way. Re-request this URL until it answers `200`.",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/JobResponse" } }
            }
          },
          "400": {
            "description": "`invalid_domain` when the path segment is not a resolvable domain. `invalid_parameter` when `?wait` is not a whole number of seconds of at least 1.",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } }
            }
          },
          "401": {
            "description": "`invalid_api_key` — the Authorization header is missing or malformed or names a key that does not verify.",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } }
            }
          },
          "404": {
            "description": "`not_found` — this domain is maintained by its owner and we hold no record to serve. Nothing is extracted for it so the answer does not change on retry.",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } }
            }
          },
          "422": {
            "description": "`extraction_failed` — this domain failed recently and is inside its negative-result window so it is not re-attempted yet.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/ExtractionFailedError" }
              }
            }
          },
          "429": {
            "description": "`rate_limited` when the API key exceeds its request window. `quota_exceeded` when the organization's monthly extraction quota is spent.",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/RateLimitedError" } }
            },
            "headers": {
              "Retry-After": {
                "description": "Whole seconds to wait before retrying.",
                "schema": { "type": "integer" }
              },
              "X-RateLimit-Remaining": {
                "description": "Requests left in the key's current window.",
                "schema": { "type": "integer" }
              },
              "X-RateLimit-Reset": {
                "description": "Unix time in seconds at which the key's window resets.",
                "schema": { "type": "integer" }
              }
            }
          },
          "451": {
            "description": "`removed` — this brand was removed at the request of its owner and is no longer served. The refusal is final for as long as the removal stands; retrying does not help.",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } }
            }
          },
          "500": {
            "description": "`internal_error` — the request authenticated but the service failed to answer it.",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } }
            }
          }
        }
      }
    },
    "/v1/brands/{domain}/refresh": {
      "post": {
        "operationId": "refreshBrand",
        "summary": "Refresh a brand",
        "description": "Forces a new extraction for a domain even when a fresh cache entry exists, and always answers `202` — collect the result from `GET /v1/brands/{domain}`, which `poll_url` points at. A refresh already under way for this domain is joined rather than duplicated, and costs nothing. Refreshes are capped at one per domain per hour and ten per organization per day, on top of the monthly extraction quota.",
        "tags": ["Brands"],
        "parameters": [
          {
            "in": "path",
            "name": "domain",
            "required": true,
            "schema": {
              "type": "string",
              "description": "The domain to look up, without a scheme. A host that redirects elsewhere is resolved to its landing host, and that host is the cache key the answer is keyed on."
            },
            "description": "The domain to look up, without a scheme. A host that redirects elsewhere is resolved to its landing host, and that host is the cache key the answer is keyed on.",
            "example": "stripe.com"
          }
        ],
        "security": [{ "BearerAuth": [] }],
        "responses": {
          "202": {
            "description": "The refresh was accepted, or an in-flight extraction for this domain was joined.",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/JobResponse" } }
            }
          },
          "400": {
            "description": "`invalid_domain` — the path segment is not a resolvable domain.",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } }
            }
          },
          "401": {
            "description": "`invalid_api_key` — the Authorization header is missing or malformed or names a key that does not verify.",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } }
            }
          },
          "402": {
            "description": "`refresh_not_included` — this plan's allowance is smaller than what an explicit refresh always costs. Upgrading resolves it.",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } }
            }
          },
          "409": {
            "description": "`brand_claimed` — this domain is maintained by its owner and is no longer re-extracted on request. The refusal costs neither a refresh window nor a quota unit.",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } }
            }
          },
          "429": {
            "description": "`refresh_rate_limited` when a refresh window for this domain or organization is spent. `quota_exceeded` when the monthly extraction quota is spent. `rate_limited` when the API key exceeds its request window.",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/RateLimitedError" } }
            },
            "headers": {
              "Retry-After": {
                "description": "Whole seconds to wait before retrying.",
                "schema": { "type": "integer" }
              }
            }
          },
          "451": {
            "description": "`removed` — this brand was removed at the request of its owner and is no longer extracted. The refusal costs neither a refresh window nor a quota unit.",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } }
            }
          },
          "500": {
            "description": "`internal_error` — the request authenticated but the service failed to answer it.",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } }
            }
          }
        }
      }
    },
    "/v1/usage": {
      "get": {
        "operationId": "getUsage",
        "summary": "Read this organization's usage",
        "description": "Reports what this organization has spent against its allowance in the current period, and the ceiling actually being enforced. The figures count ANSWERED CALLS: a domain already held costs the same as one nobody has read yet, and an explicit refresh costs more than either because it always forces a fresh extraction. An organization sitting at its ceiling is refused rather than served. There is no organization parameter: the key decides the tenant. An organization that has never called anything reads as a zeroed standing rather than a 404.",
        "tags": ["Usage"],
        "parameters": [],
        "security": [{ "BearerAuth": [] }],
        "responses": {
          "200": {
            "description": "This organization's standing for the current period.",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/UsageStanding" } }
            },
            "headers": {
              "X-RateLimit-Limit": {
                "description": "Requests allowed in the key's window.",
                "schema": { "type": "integer" }
              },
              "X-RateLimit-Remaining": {
                "description": "Requests left in the key's current window.",
                "schema": { "type": "integer" }
              },
              "X-RateLimit-Reset": {
                "description": "Unix time in seconds at which the key's window resets.",
                "schema": { "type": "integer" }
              }
            }
          },
          "401": {
            "description": "`invalid_api_key` — the Authorization header is missing or malformed or names a key that does not verify.",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } }
            }
          },
          "429": {
            "description": "`rate_limited` — the key exceeded its request window.",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/RateLimitedError" } }
            },
            "headers": {
              "Retry-After": {
                "description": "Whole seconds to wait before retrying.",
                "schema": { "type": "integer" }
              }
            }
          },
          "503": {
            "description": "`service_unavailable` — the key verified but the ledger could not be read. Retry rather than rotate the key.",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } }
            }
          }
        }
      }
    },
    "/v1/webhooks": {
      "get": {
        "operationId": "listWebhookEndpoints",
        "summary": "List webhook endpoints",
        "description": "Every webhook endpoint this organization has registered along with the outcome of the last delivery attempted against each one. Signing secrets are never returned by this endpoint or by any other read — the secret is shown once when the endpoint is created.",
        "tags": ["Webhooks"],
        "parameters": [],
        "security": [{ "BearerAuth": [] }],
        "responses": {
          "200": {
            "description": "The organization's endpoints. The list is empty when none are registered.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/WebhookEndpointList" }
              }
            }
          },
          "401": {
            "description": "`invalid_api_key` — the Authorization header is missing or malformed or names a key that does not verify.",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } }
            }
          },
          "429": {
            "description": "`rate_limited` — the API key exceeded its request window.",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/RateLimitedError" } }
            }
          },
          "503": {
            "description": "`service_unavailable` — the request authenticated but the registry could not be read.",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } }
            }
          }
        }
      },
      "post": {
        "operationId": "registerWebhookEndpoint",
        "summary": "Register a webhook endpoint",
        "description": "Registers an HTTPS URL that receives `brand.extraction.completed` and `brand.extraction.failed` deliveries for every extraction this organization is waiting on — including one another organization triggered and this one joined. The response carries `signing_secret` and it is the only time that value is ever returned. The URL must be HTTPS and its hostname must not be a loopback, private, link-local or cloud-metadata address; the hostname is checked as written and is never resolved, and the same check runs again immediately before every delivery. An organization may register up to five endpoints.",
        "tags": ["Webhooks"],
        "parameters": [],
        "security": [{ "BearerAuth": [] }],
        "requestBody": {
          "content": {
            "application/json": {
              "schema": { "$ref": "#/components/schemas/WebhookRegisterRequest" }
            }
          }
        },
        "responses": {
          "201": {
            "description": "The endpoint was registered. Store `signing_secret` now — no later read returns it.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/WebhookEndpointCreated" }
              }
            }
          },
          "400": {
            "description": "`invalid_webhook_url` when the URL broke one of the target rules — `error.reason` names which. `invalid_parameter` when the body carried no `url` string.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/WebhookUrlRejectedError" }
              }
            }
          },
          "401": {
            "description": "`invalid_api_key` — the Authorization header is missing or malformed or names a key that does not verify.",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } }
            }
          },
          "409": {
            "description": "`webhook_url_registered` when this organization already registered that URL. `webhook_limit_reached` when it already holds the maximum number of endpoints.",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } }
            }
          },
          "429": {
            "description": "`rate_limited` — the API key exceeded its request window.",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/RateLimitedError" } }
            }
          },
          "503": {
            "description": "`service_unavailable` — the request authenticated but the registry could not be written.",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } }
            }
          }
        }
      }
    },
    "/v1/webhooks/{endpoint_id}": {
      "delete": {
        "operationId": "deleteWebhookEndpoint",
        "summary": "Delete a webhook endpoint",
        "description": "Stops deliveries to an endpoint. The endpoint becomes `disabled` rather than disappearing so its delivery history stays readable and the change is reversible by registering the URL again. A delivery already queued for this endpoint is dropped rather than sent — the target is re-read at delivery time. An endpoint id that belongs to another organization answers `404` exactly as an unknown one does.",
        "tags": ["Webhooks"],
        "parameters": [
          {
            "in": "path",
            "name": "endpoint_id",
            "required": true,
            "schema": {
              "type": "string",
              "description": "The endpoint's own id, as returned when it was registered."
            },
            "description": "The endpoint's own id, as returned when it was registered.",
            "example": "whep_2f6c0f1e-6f2a-4c53-9a1e-8b0f7c3a1d20"
          }
        ],
        "security": [{ "BearerAuth": [] }],
        "responses": {
          "200": {
            "description": "The endpoint is disabled and will receive no further deliveries.",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/WebhookEndpoint" } }
            }
          },
          "401": {
            "description": "`invalid_api_key` — the Authorization header is missing or malformed or names a key that does not verify.",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } }
            }
          },
          "404": {
            "description": "`not_found` — this organization has no endpoint with that id.",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } }
            }
          },
          "429": {
            "description": "`rate_limited` — the API key exceeded its request window.",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/RateLimitedError" } }
            }
          },
          "503": {
            "description": "`service_unavailable` — the request authenticated but the registry could not be written.",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } }
            }
          }
        }
      }
    },
    "/v1/webhooks/{endpoint_id}/test": {
      "post": {
        "operationId": "testWebhookEndpoint",
        "summary": "Send a test delivery",
        "description": "Sends one signed `webhook.test` delivery to a registered endpoint and answers with what the receiver did. The payload carries no brand data by design — a probe that arrived as a real extraction event would push invented data through your production handler. Exactly one attempt is made and it is never retried; the attempt is recorded in the endpoint's delivery history alongside its real deliveries.",
        "tags": ["Webhooks"],
        "parameters": [
          {
            "in": "path",
            "name": "endpoint_id",
            "required": true,
            "schema": {
              "type": "string",
              "description": "The endpoint's own id, as returned when it was registered."
            },
            "description": "The endpoint's own id, as returned when it was registered.",
            "example": "whep_2f6c0f1e-6f2a-4c53-9a1e-8b0f7c3a1d20"
          }
        ],
        "security": [{ "BearerAuth": [] }],
        "responses": {
          "200": {
            "description": "The attempt was made. `delivered` reports whether the receiver answered 2xx.",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/WebhookTestResult" } }
            }
          },
          "401": {
            "description": "`invalid_api_key` — the Authorization header is missing or malformed or names a key that does not verify.",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } }
            }
          },
          "404": {
            "description": "`not_found` — this organization has no active endpoint with that id.",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } }
            }
          },
          "429": {
            "description": "`rate_limited` — the API key exceeded its request window.",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/RateLimitedError" } }
            }
          },
          "503": {
            "description": "`service_unavailable` — the request authenticated but the registry could not be read.",
            "content": {
              "application/json": { "schema": { "$ref": "#/components/schemas/ApiError" } }
            }
          }
        }
      }
    }
  }
}
