# Brandcurl v1 integration contract

This page is intentionally small, static, and JavaScript-free. The full OpenAPI
document is at [`/v1/openapi.yaml`](/v1/openapi.yaml). The interactive reference
is at [`/docs`](/docs).

## Authentication

Hosted `/v1` endpoints require an API key minted from **Dashboard → API keys**.
Keys begin with `bc_live_`. Keep the key in server-side secret storage under the
canonical environment variable `BRANDCURL_API_KEY` and send it as a Bearer token:

[Sign in and open the dashboard key flow](/?signin=1&next=%2F%3Fview%3Ddashboard).

```sh
curl -H "Authorization: Bearer $BRANDCURL_API_KEY" \
  https://brandcurl.com/v1/brands/stripe.com
```

Never put an API key in a URL, browser bundle, generated asset, log, or repository.
The older `BRANDCURL_KEY` environment name remains accepted by the official MCP
adapter for compatibility.

## Brand response shape

`GET /v1/brands/{domain}` returns the `Brand` schema. A contract-complete,
synthetic response fixture is available at
[`/docs/examples/brand-response.json`](/docs/examples/brand-response.json).
It is not a claim about a live company. Real extraction responses may
legitimately contain empty arrays or an empty `company` object when Brandcurl
cannot support a value with evidence.

Important closed enums:

- `logos[].type`: `logo`, `icon`, `symbol`
- `logos[].theme`: `light`, `dark` — the background the mark belongs on
- `logos[].variant`: `default`, `wide`, `horizontal`, `vertical`, `stacked`, `square`, `compact`, `wordmark`, `symbol`
- `colors.palette[].type`: `brand`, `accent`, `light`, `dark`
- `colors.palette[].role`: `primary`, `accent`, `background`, `text`
- `fonts[].type`: `title`, `body`, `mono`
- `fonts[].origin`: `google`, `custom`, `system`
- `company`: optional string fields `legalName`, `foundedYear`, `email`, `phone`, `address`, `ticker`, `employees`, `headquarters`, `industry`

Treat unknown object fields as additive. Treat empty arrays, empty objects, and
absent optional fields as abstentions, not as parsing failures.

## Do not pay twice for unchanged data

Persist the response body and its `ETag`, keyed by the canonical `domain` in the
response. On the next read, send `If-None-Match` with that ETag:

```http
GET /v1/brands/stripe.com HTTP/1.1
Authorization: Bearer bc_live_…
If-None-Match: "previous-etag"
```

- `200`: replace the cached body and ETag.
- `304`: the body is intentionally empty; reuse the cached body. A 304 is not charged.
- `X-Cache`: reports server cache state.
- `RateLimit-Limit`, `RateLimit-Remaining`, `RateLimit-Reset`: abuse-control state, not billing units.
- `X-Balance-Remaining`: prepaid wallet dollars remaining after a priced response.
- `X-Request-ID`: retain for support and diagnostics.

Only successful non-replayed `2xx` calls to priced routes debit the wallet.
Errors, `304` responses, and idempotent replays do not. Current prices and free
routes are authoritative at [`/pricing`](/pricing); do not hardcode pricing from
this document.

Priced routes still require enough balance for admission before checking the
validator; a depleted wallet can return `402` even for conditional reads. Keep
your last good response locally. The stdio adapter's cache is process-local,
not durable across exports that start a new process.

## Which endpoint to call

| Need | Endpoint | Policy |
|---|---|---|
| Colors, logos, fonts, basic company fields | `GET /v1/brands/{domain}` | Default integration path. Use ETag revalidation. |
| Fresh crawl after a customer asks for it | `POST /v1/brands/{domain}/refresh` | Explicit only. Send `Idempotency-Key` (optional in the API); poll the returned job. |
| Materialized voice, audiences, messaging, events, and signals | `GET /v1/contexts/{domain}` | Optional for copy/strategy workflows; not needed for visual identity export. |
| Rebuild selected context modules | `POST /v1/contexts/{domain}/refresh` | Explicit, budgeted work. Use the request budget and idempotency controls. |
| Separate source-attributed firmographics | `GET /v1/enrichments/{domain}` | Optional; a sparse/not-found result is valid until enrichment exists. |
| Best embeddable logo bytes | `GET /v1/logo/{domain}` | Free route. Inspect selection headers and cache the returned bytes/ETag. |
| Best logo source URL and format | `GET /v1/logo/{domain}?output=json` | Same free route, returning `{url, format}`. Source URLs are upstream assets, not permanent hosted links. |

For long-lived customer links, store the logo bytes in your own durable asset
storage. Neither a source URL nor its metadata ETag guarantees the upstream
image will remain available or unchanged. Keep API keys on your server; do not
embed an authenticated Brandcurl endpoint directly in a public image tag.

Use a new idempotency key for each new operation and reuse it only for retries
of that operation. Enrichment refresh requires one; brand/context refresh accept
one optionally. Do not copy a fixed example key into every request.

Do not automatically turn a sparse brand response into a refresh, context
refresh, enrichment refresh, or research job. Those are distinct products with
distinct prices and may perform live/provider work. Make them explicit customer
actions or deliberate application policy.

## Domain input

Pass the organization’s real public website hostname, such as `stripe.com`.
Brandcurl cannot infer a customer domain from a repository name. Reject template
placeholders such as `yourwebsite.com`; require an explicit `--domain` (or an
equivalent configuration value) when a repository does not declare a verified
public hostname.

## Errors

Errors use `{ "error": { "code", "message", "requestId", "retryable?", "details?" } }`.
Parse `code`, not the human message. A hosted `401 unauthorized` includes links
to this contract and the key-creation flow. Respect `Retry-After` on `429` and
retryable server failures.

When the wallet cannot cover a priced request, the API returns **HTTP 402** with
`error.code: "payment_required"`. No work starts and nothing is charged.
`error.details` contains `balanceUsd`, `priceUsd`, and `topUpUrl`;
`X-Balance-Remaining` reports the same available balance in dollars. Show the
billing problem to the account owner and retry after replenishing the wallet.
Repeated immediate retries cannot fix insufficient credit. Logo and search
remain free, although authentication and abuse-control limits still apply.

An unavailable accounting backend returns **503 `accounting_unavailable`**.
It is a service failure, not a claim that the customer has no credit. Preserve
the request ID and retry with bounded backoff. An invalid or revoked key returns
**401**, and an abuse-control limit returns **429**; neither is a wallet balance.

## Company and people data

The company/people API has its own complete [OpenAPI 3.1 contract](/v1/data/openapi.json)
and appears in the [interactive reference](/docs#enrich-company). Use your
**Brandcurl account API key**. Brandcurl supplies the upstream credentials;
customers do not provide Apollo keys. The older `/v1/enrichments/{domain}`
provider-configured API is a separate product.

Start with free `GET /v1/data/capabilities`. For each operation it returns
`enabled`, `reason`, `supportedFilters`, `allowedValues`, `maxItems`,
`maxPageSize`, `priceVersion`, `unit`, and `maximumUnitCharge`. An unconfigured
or unpriced purchase returns `503 data_unavailable` without a charge. Read the
current capability price before submitting work; documentation examples do not
set commercial prices.

Production company enrichment was activated on September 8, 2026 at **$0.40
per matched company**, price version `2026-09-08-company-enrichment-v1`.
Use the same production account key and `https://brandcurl.com`; staging has
separate accounts and keys. The [pricing page](/pricing#managed-price-title)
renders configured prices directly from the same capabilities used by the API.
Check capabilities again before buying, and set the single-company `maxCharge`
to `{"currency":"USD","amount":"0.40"}` if that is within your budget.
Other operations remain unavailable until they report `enabled: true`.

| Need | Endpoint | Billing |
|---|---|---|
| Enrich a company from domain, website, LinkedIn or name | `POST /v1/companies/enrich` | Async; matched record |
| Retrieve company details using an owned ID | `GET /v1/companies/{companyId}` | Returned record |
| Search companies by size, revenue, funding, technology or location | `POST /v1/companies/search` | Successful page |
| Enrich up to ten companies | `POST /v1/companies/bulk-enrich` | Async; distinct matched records |
| Search people by role, seniority, location or company | `POST /v1/people/search` | Successful page |
| Retrieve a person profile using an owned ID | `GET /v1/people/{personId}` | Returned record |
| Match/enrich a person | `POST /v1/people/enrich` | Async; matched record |
| Enrich up to ten people | `POST /v1/people/bulk-enrich` | Async; distinct matched records |
| List a company's hiring activity | `GET /v1/companies/{companyId}/jobs` | Successful page |
| Search company news | `POST /v1/companies/news/search` | Successful page |
| Reveal a requested work/personal email | `POST /v1/people/{personId}/contact-reveals` | Async; returned requested contact |
| Read retained technology, funding, ownership or workforce data | `GET /v1/companies/{companyId}/technologies`, `/funding`, `/ownership`, `/workforce` | Free; no new lookup |
| Poll a data job and read its settled result | `GET /v1/data/jobs/{jobId}`, `GET /v1/data/jobs/{jobId}/result` | Free; reports the original purchase's billing |

All POSTs require `Idempotency-Key` (1–128 visible ASCII characters). Send it on
paid GETs too: without it, each GET is a new purchase. Preserve the key and exact
request for a retry. Reusing the key with different input returns `409`.
Managed data responses are `private, no-store`; the brand API's ETag/304 rules
above do not apply to these purchases.

Async requests require `maxCharge`, your total ceiling as
`{"currency":"USD","amount":"1.00"}`. That amount is an example budget, not a
posted price. For bulk enrichment, cover the maximum for every distinct lookup.
A `202` reserves the posted maximum and returns `data.statusUrl` and
`data.resultUrl`; poll status until `complete` or `failed`. Result reads return
`409` until settlement. Inspect every `data.items[].status` (`matched`,
`no_match`, `ambiguous`, or `error`); completion does not guarantee a match.
The `billing` envelope reports exact decimal `reserved`, `charged`, `released`,
`units`, and `priceVersion`.

No-match, ambiguous results and supplier failures have no customer charge.
Duplicate matches are billed once. A successful search/news/jobs page is billed
once even when empty. Free projections require a retained enriched/detail
profile; they return `404` if unavailable and cannot refresh it. Public company,
person and job IDs are tenant-owned: obtain them from your own responses, never
copy IDs from another account or from the synthetic documentation fixtures.
Results expire after 24 hours by default. Expired purchase keys return `410` and
never automatically buy again.

A depleted wallet returns `402 payment_required` before a supplier call, with
`balanceUsd`, `priceUsd`, `topUpUrl` and `X-Balance-Remaining`. Top up and retry
the same denied key. Supplier authentication, scope or quota failures return
sanitized `503 data_unavailable`; any hold is released. Replaying a failed
purchase returns that same failure without buying again. A new supplier attempt
requires a deliberate new operation/key; do not loop on new keys.

Company and person pages allow up to 100 results, news pages up to 25, and bulk
requests up to ten inputs. Phone and waterfall options currently return
`400 capability_unavailable` before purchase. Unknown fields remain null;
obfuscated identities and locked contact placeholders are not verified contact
data. All examples are synthetic. These routes are REST APIs; the existing four
MCP tools do not yet expose the expanded data operations.
