# extractbrand > Point it at a domain. Get the logos, colour roles and type back, measured from the page as it renders. extractbrand is a brand extraction API. Give it a domain; it opens the site in a real browser, measures what actually renders — painted colour area, resolved font faces, the served logo files — and answers with one versioned JSON shape. Results land in a shared cache, so a domain anyone has already read answers in milliseconds rather than seconds — the cache buys latency, and every answered call costs the same one unit either way. ## What comes back - Logos: the mark at the URL the site serves it from, a light variant when the site publishes one, the source (inline SVG, header img, og:image) and the mark's dominant colours. - Colour: one ranked list. Every colour carries a `role` (primary, secondary, accent, background, surface, text — or null for a colour the page paints that fills no role), a text colour legible `on` it, the site's own CSS custom-property `names` for it, and the `coverage` it paints of the rendered page. Ranked by painted area on the rendered page rather than frequency in the CSS. - Type: heading and body faces resolved separately, each marked Google font, custom webfont or system fallback — the faces that rendered, not the ones the stylesheet asked for. - Voice: name, tagline, positioning and tone words, with a confidence figure per extraction and the unresolved fields named in `gaps`. ## API Base URL: https://api.extractbrand.dev Every request carries `Authorization: Bearer eb_...` — one key, issued in the dashboard. Get one at https://extractbrand.dev/sign-up. The key identifies the organization, so no organization parameter is ever sent — not in a path, a query string or a request body. - `DELETE /v1/webhooks/{endpoint_id}` — stop deliveries to an endpoint. It becomes `disabled` rather than disappearing so its delivery history stays readable. - `GET /v1/brands/{domain}` — cache-first read. A cached domain answers 200 in milliseconds; a first-time domain answers 202 with a job envelope whose `poll_url` is this same domain URL — poll the domain, not a job id. `?wait=N` holds the request open for up to 25 seconds, so one call can return the brand. The 200 body reports its own quality: `quality_verdict` is `complete`, `partial` or `thin`, and `missing_fields` names the fields this read did not find. The verdict is decided by the six core fields alone — `name`, `logo`, `colors`, `fonts`, `tagline`, `description` — so `complete` can arrive beside a non-empty `missing_fields` naming any other field, such as `industry` or `tone`. 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 say what we did not find, never what the company does not have. Every 200 also carries a `provenance` block saying who authored the values: `source` is `owner` when a company that proved control of the domain wrote any field in this response, and `measured` when nothing in it was written by hand. `owner_fields` names the dotted paths they wrote, and the list is sparse on purpose — every field it does not name was measured from the site itself, so you never have to read a per-field marker to know. A domain nobody has claimed answers `measured` with an empty list, and the block says nothing else about claim status: no flag, no badge, no company named. One honest consequence of that, because the answer looks wrong otherwise: a claimed record is frozen — we stop re-reading the site — so once its window passes, `cache_status` reports `stale` and goes on reporting it. Here that word means nobody re-measured it, never that the brand it describes is out of date, and the owner is the one keeping it current. - `GET /v1/me` — which organization the calling key acts on. - `GET /v1/usage` — what this organization has spent this period and the ceiling being enforced. Counts ANSWERED CALLS — a domain already held costs the same as one nobody has read yet, and a refresh costs more than either. - `GET /v1/webhooks` — the endpoints this organization has registered, each with the outcome of the last delivery attempted against it. - `POST /v1/billing/checkout` — open a Stripe-hosted Checkout session for a plan and return its URL. A tier with no price yet answers 503 `plan_unavailable` before any Stripe object is created. - `POST /v1/billing/portal` — open the Stripe Billing Portal for the organization's existing customer. An organization that has never checked out answers 409 `no_billing_account` rather than having a customer created for it. - `POST /v1/brands/{domain}/refresh` — force a re-extraction. A domain whose owner has proved control answers 409 `brand_claimed` and is never re-extracted: the record is theirs to change, and the refusal costs neither a refresh window nor a billable unit. - `POST /v1/webhooks` — register an HTTPS endpoint so extraction results are pushed to you instead of polled. The response carries `signing_secret` and is the only time it is ever returned. - `POST /v1/webhooks/{endpoint_id}/test` — send one signed `webhook.test` delivery and report what the receiver did. Carries no brand data and is never retried. - `PUT /v1/billing/ceiling` — cap billable units per period. Null clears the cap and falls back to the plan allowance; zero is legal and refuses every billable call with 429 until it is raised or the period rolls. What you send is stored — the clamp is applied on read. Machine-readable contract: https://extractbrand.dev/openapi.json (OpenAPI 3.1, generated from the runtime's own schemas). Human reference: https://extractbrand.dev/reference. ## Webhooks Register an HTTPS endpoint and every extraction your organization is waiting on is POSTed to it when it finishes — including one another organization triggered and you joined. The management endpoints are listed above; the delivery itself is an outbound request, so its contract is here. - Body: `{ object: "event", event_id, event_type, created_at, job_id, data }`. - `event_type` is `brand.extraction.completed`, `brand.extraction.failed` or `webhook.test`. Switch on it and ignore anything unrecognised — the list is additive. - On `completed`, `data` is the `GET /v1/brands/{domain}` 200 body minus its `quota` block. On `failed`, it is `{ object: "extraction_failure", domain, failure_reason }` with the same closed reason vocabulary the API serves. - Quality and failure are two axes and never appear together. A `completed` delivery carries `quality_verdict` (`complete`, `partial`, `thin`, or `null` for an entry that predates the field) and `missing_fields`, and no `failure_reason`; a `failed` delivery carries a `failure_reason` and no verdict. So a thin read arrives as a `completed` event you can inspect, not as a failure, and there is no third event type to switch on. - Signature: `x-extractbrand-signature: t=,v1=`, where the digest is `HMAC-SHA256(secret, ".")`. Verify against the raw bytes, compare in constant time, and reject a `t` more than 300 seconds from your clock. - At-least-once. Answer 2xx once you have durably accepted the event. 429 and 5xx are retried up to five attempts (30s / 2m / 8m / 32m); every other 4xx is not, and redirects are never followed. Deduplicate on `event_id` — it is stable across retries and distinct per endpoint. - Deliveries are never metered and never affect the extraction itself. ## Quickstart Sign up, mint a key, make one request, and read what comes back. ## 1. Get a key [Sign up](https://extractbrand.dev/sign-up) for a free account, then mint a key from the first card in the [dashboard](https://extractbrand.dev/dashboard). The new key is shown once, in the dialog that mints it. What we store is a hash, so nothing can show it to you a second time — copy it while it is in front of you, or mint a replacement. Every key starts with `eb_`. That dialog also carries the command in step 2 with your real key already in it. Copy that one and skip the edit below. ## 2. Make your first call ```bash curl -H "Authorization: Bearer YOUR_API_KEY" https://api.extractbrand.dev/v1/brands/stripe.com ``` Swap `YOUR_API_KEY` for the key from step 1. The header is `Authorization: Bearer YOUR_API_KEY`, and a missing or wrong key answers `401`. The same call in JavaScript, in Python, and as an MCP server an agent can call is in that dialog too, and every field it can return is in the [reference](https://extractbrand.dev/reference). ## 3. What came back One request has two possible answers and both are success. A domain someone has read before comes out of the shared cache as a `200` with the brand in it; a domain nobody has read yet answers `202`, which means the read has started, not that the call was refused. ### A cached domain: 200 ```json { "object": "brand", "domain": "stripe.com", "cache_status": "hit", "extracted_at": "2026-07-19T11:04:07.000Z", "expires_at": "2026-08-18T11:04:07.000Z", "quality_verdict": "complete", "missing_fields": [], "brand": { "name": "Stripe", "logo": { "url": "https://stripe.com/img/logo.svg", "source": "inline-svg" }, "colors": [ { "hex": "#635bff", "role": "primary", "on": "#ffffff", "names": ["--brand"], "coverage": 0.08 }, { "hex": "#0a2540", "role": "text", "on": "#ffffff", "names": [], "coverage": 0.11 } ], "fonts": [ { "family": "Sohne", "role": "display", "availability": "custom-webfont" } ] }, "provenance": { "source": "measured", "owner_fields": [] }, "quota": { "used": 7, "limit": 10, "period_end": "2026-08-01T00:00:00.000Z" } } ``` The sample is trimmed to the fields named below; every field the payload carries is in the [reference](https://extractbrand.dev/reference). - `object` — what this payload is. `brand` here, `job` for the `202` below. - `domain` — the canonical key the entry is cached under. - `cache_status` — `hit` for an entry inside its window, `stale` for one past it that is being re-read behind your response. - `extracted_at` and `expires_at` — when the site was last read, and when the entry stops counting as current. - `quality_verdict` — how much of the brand this read came back with: `complete`, `partial`, or `thin` when so little resolved that the payload has stopped being usable. `null` means the entry predates this field and was never assessed — read it as unknown, never as `complete`. - `missing_fields` — the fields this read did not find, so a blank is a fact rather than a guess. `[]` when nothing is missing, `null` when the verdict is `null` and nothing was assessed. It says what we did not find, not what the company does not have. - `brand.colors` — every colour of the brand as one ranked list, each with its `hex`, a text colour that is legible `on` it, the `names` the site's own stylesheet gives it, and the `coverage` it paints of the rendered page. The entries with a `role` are the brand's paint kit; an entry with `role: null` is a colour the page paints that fills no role. - `brand.fonts` — the faces that rendered, display first, each marked with how it can be loaded. - `brand.logo` — the mark at the URL the site itself serves it from, and where on the page it was found. - `provenance` — which fields here the brand's owner authored, and which were measured from their site. `owner_fields` names the authored ones and every field it does not name was measured, so `source` reads `owner` only when that list is non-empty. - `quota` — units spent this period, the ceiling, and when the period rolls over. A site that publishes no logo still answers `200`. Whatever no specialist could fill is named in `gaps` and, for the fields this contract publishes, in `missing_fields` beside the verdict — so a thin payload reads as a thin read rather than a failed call, and you never have to guess whether an empty field means we looked and found nothing or never looked at all. ### A first read: 202 ```json { "object": "job", "job_id": "job_0f6d8c1e-4a2b-4d7c-9e13-5b0a2c8f4d61", "domain": "stripe.com", "status": "queued", "poll_url": "/v1/brands/stripe.com", "quota": { "used": 8, "limit": 10, "period_end": "2026-08-01T00:00:00.000Z" } } ``` `poll_url` is the domain URL you just called — poll the domain, not a job id. Adding `?wait=25` holds the request open until the read finishes, so one call returns the brand. ## 4. What the free tier includes - `10` answered calls a month. Every call that returns a brand counts, whether we extract it for you or serve one we already hold. - An explicit refresh costs more than a read, because it always performs a fresh extraction — more than the free tier holds, so refreshing is a paid-tier call. - `120 requests per minute`, per key. Over that the API answers `429`. ## 5. Point it at your own domain Your own domain will not be in the cache yet, so the first call answers the `202` from step 3 — the read has to open the site. Add `?wait=25` to the URL and that first call returns the brand instead of the job. After that the brand stays cached for `30` days. Past that it is still served immediately for the same one unit any call costs, and the re-read running behind your response is free — it is our freshness policy acting, not a call you made. `POST /v1/brands/{domain}/refresh` forces that re-read on demand, for a site that has changed since we last picked it up. Swap `stripe.com` for your own domain and run it again. ## MCP A hosted MCP server answers at https://api.extractbrand.dev/v1/mcp over streamable HTTP, authenticated with the same bearer key. Tools: - `get_brand` - `refresh_brand` - `get_usage` ## Pricing Every answered call costs one unit, whether the brand was already held or had to be extracted — `cache_status` tells you what latency to expect, never what you were charged. An explicit refresh costs more, because it always forces a fresh extraction. The free tier includes 10 calls a month and cannot afford a refresh at all. Growth is $20 a month and includes 500 answered calls; each call past them is $0.01. Figures are USD; checkout charges the local-currency equivalent. ## Brand owners Brand data is read from each company's own public pages. If you control a domain and want it out, mail legal@extractbrand.dev: we verify that you control the domain, then an operator removes the entry and the domain stops being readable. Details: https://extractbrand.dev/dmca. ## Legal - Terms: https://extractbrand.dev/terms - Privacy: https://extractbrand.dev/privacy - Acceptable use: https://extractbrand.dev/acceptable-use