# favi — company logos by domain

> A logo API for developers. `GET https://img.favi.sh/{domain}?key=pk_…` returns the
> company's logo as an image; the same publishable key resolves logos by
> company name and stock ticker, and returns brand data (colors, blurhash,
> description, socials) as JSON. No SDK: the URL goes straight into an
> `<img>` tag.

- API host: `https://img.favi.sh` (everything that takes a publishable key)
- Site, dashboard and account APIs: `https://favi.sh`
- OpenAPI 3.1: https://favi.sh/openapi.json · Short summary: https://favi.sh/llms.txt · Human docs: https://favi.sh/docs

## Quickstart

1. Sign up at https://favi.sh/signin (magic link, Google or GitHub — no card on the Free plan).
2. Create a key at https://favi.sh/app/keys. Keys start with `pk_` and are publishable: they ship in page
   source and are not secrets. Set the key's domain allowlist to your sites.
3. Put the URL in an image tag:

```html
<img src="https://img.favi.sh/stripe.com?key=pk_your_key" width="32" height="32" alt="">
```

That is the whole integration. Unknown domains answer with a generated lettermark immediately
(HTTP 202) while the real logo is crawled, usually landing within seconds.

## Endpoints

| Method | Path | Returns |
|---|---|---|
| GET | `https://img.favi.sh/{domain}` | The logo as an image (WebP by default) |
| GET | `https://img.favi.sh/name/{brand}` | 302 to the top brand-search match's image URL |
| GET | `https://img.favi.sh/ticker/{symbol}` | 302 to the listing's domain image URL |
| GET | `https://img.favi.sh/search?q=…` | JSON: up to 10 `{name, domain, logoUrl}` matches |
| GET | `https://img.favi.sh/brand/{domain}` | JSON brand profile: name, description, colors, blurhash, socials, logo URLs |
| POST | `https://favi.sh/api/refresh` | Signed in: re-crawl one domain and purge it from the edge |

Every public endpoint takes `?key=pk_…`. `token` is accepted as an alias for `key` so a
migration from logo.dev is a hostname swap. All responses carry
`access-control-allow-origin: *`.

## Image endpoint

```
GET https://img.favi.sh/{domain}?key=pk_your_key
```

The domain goes in the path. Scheme, `www.`, paths and casing are stripped, so
`https://www.stripe.com/pricing`, `WWW.STRIPE.COM` and `stripe.com` all resolve to one cached
image.

### Parameters

| Parameter | Values | Notes |
|---|---|---|
| `key` | `pk_…` | Required. `token` is an alias. |
| `format` | `webp` (default), `avif`, `png`, `jpg` | AVIF is smaller again and keeps transparency; JPG flattens onto white. |
| `theme` | `light` (default), `dark` | `dark` serves a white-recolored variant of dark, near-monochrome marks; colorful logos serve unchanged, so it is always safe to set. |
| `size` | 1–800 | Downscale to fit. Never upscaled past the stored master (max 256 px). |
| `width`, `height` | integer | Exact box (aliases `w`, `h`). Override `size`. |
| `retina` | `true` | Doubles the requested dimensions. |
| `greyscale` | `true` | Desaturates — the logo-wall pattern. |
| `fallback` | `404` | Return HTTP 404 on any miss instead of a generated lettermark. |

Prefer omitting `size`: one image is stored per domain, at the resolution the site publishes
(up to 256 px), so a 12 px inline mention and a 32 px avatar share a single cache entry when the
browser scales via the `<img>` `width`/`height` attributes. `size` is for the places that need
real pixels: canvas, PDFs, email.

There is no `theme=auto`: a CDN cannot see the user's color scheme. Resolve it client-side:

```html
<picture>
  <source media="(prefers-color-scheme: dark)"
          srcset="https://img.favi.sh/github.com?key=pk_your_key&theme=dark">
  <img src="https://img.favi.sh/github.com?key=pk_your_key" width="32" height="32" alt="">
</picture>
```

### Response

- `200` + image: a real logo, or — on a rejected key, over-quota account, or a domain with no
  usable logo — a generated lettermark. **A well-formed request never returns a broken image.**
- `202` + image: a lettermark while the first crawl runs; cached 30 seconds. Fetch again shortly.
- `404`: only with `fallback=404`.
- `400`: the one exception — a malformed domain.

The `x-favi-result` header says what was served:

| x-favi-result | Meaning |
|---|---|
| `hit` | A real logo |
| `lettermark:pending` | Crawling now (202); try again shortly |
| `lettermark:failed` | No usable logo exists for this domain |
| `lettermark:blocked` | Removed at the owner's request (see https://favi.sh/legal/takedown) |
| `lettermark:over-quota` | Monthly limit reached |
| `lettermark:unknown-ticker` | `/ticker` only: symbol not in the listings |
| `rejected:missing` | No key supplied |
| `rejected:unknown` | Key does not exist |
| `rejected:disabled` | Key was revoked |
| `rejected:referer` | Key is not allowed on this site (allowlist) |
| `rejected:rate-limited` | Over the per-key burst limit |

Cache-Control: real logos `max-age=86400, s-maxage=2592000` (30 days at the edge, purged on
refresh); pending lettermarks 30 seconds; rejections `no-store`.

### Subdomains

A subdomain with no logo of its own inherits its registrable parent's, resolved against the
public suffix list: `app.stripe.com` → stripe's mark; `news.bbc.co.uk` → `bbc.co.uk`, never
`co.uk`. A subdomain that publishes its own icon wins once crawled.

## Search: name → domain

```
GET https://img.favi.sh/search?q=stri&key=pk_your_key
```

```json
{ "results": [ { "name": "Stripe", "domain": "stripe.com", "logoUrl": "https://img.favi.sh/stripe.com?key=pk_your_key" } ] }
```

Up to 10 matches, exact first, then real-world popularity. Built for company autocomplete.
Prefix matching starts at two characters; a single character returns exact matches only
(`x` finds X, not everything beginning with x). A search never queues a crawl.

```
GET https://img.favi.sh/name/Stripe?key=pk_your_key
```

The image form: resolves the top match and 302s to its `/{domain}` URL with every image
parameter carried through. An unknown name degrades to a lettermark.

## Ticker: logo by stock symbol

```
GET https://img.favi.sh/ticker/AAPL?key=pk_your_key
GET https://img.favi.sh/ticker/SHOP.TO?key=pk_your_key&size=32
```

302 to the listing's domain logo, image parameters carried through. Symbols are
case-insensitive. A bare symbol is a US listing (NYSE, Nasdaq, AMEX, OTC); share classes take
`BRK.B` or `BRK-B`; other exchanges use the Yahoo-style suffix (`SHOP.TO`, `7203.T`,
`0700.HK`, `RELIANCE.NS`, `BHP.AX`). 32 exchanges, seeded from Wikidata plus the SEC's US
list. An unknown ticker is never guessed into a domain: lettermark of the symbol
(`x-favi-result: lettermark:unknown-ticker`) or 404 with `fallback=404`. No crawl, nothing
metered.

## Brand profile

```
GET https://img.favi.sh/brand/stripe.com?key=pk_your_key
```

```json
{
  "domain": "stripe.com", "status": "ok",
  "name": "Stripe", "brandColor": "#635bff",
  "colors": [
    { "hex": "#635bff", "r": 99, "g": 91, "b": 255, "share": 0.873 },
    { "hex": "#0a2540", "r": 10, "g": 37, "b": 64, "share": 0.127 }
  ],
  "blurhash": "LdI5Twoh~ot9ohfRoffR~oofWPob",
  "description": "Financial infrastructure to grow your revenue",
  "socials": { "twitter": "https://twitter.com/stripe" },
  "logos": {
    "webp": "https://img.favi.sh/stripe.com?key=pk_your_key",
    "png": "https://img.favi.sh/stripe.com?key=pk_your_key&format=png",
    "jpg": "https://img.favi.sh/stripe.com?key=pk_your_key&format=jpg"
  }
}
```

- `colors`: up to five, read off the logo's visible pixels, most prominent first, each with its
  share. Near-white is omitted unless it is the only color.
- `brandColor`: the first color that can carry a letter legibly (what the fallback mark uses).
- `blurhash`: the logo on white, 4×3 components, for a placeholder while the image loads.
- Fields the site never published are `null`, never invented.
- Unknown domain: `202 {"domain":"…","status":"pending"}` while the first crawl runs.
- Included on every plan, counted as one ordinary request.

## Code samples

**curl**

```bash
curl -o stripe.webp "https://img.favi.sh/stripe.com?key=pk_your_key"
curl -sI "https://img.favi.sh/stripe.com?key=pk_your_key" | grep -i x-favi-result
curl -s "https://img.favi.sh/brand/stripe.com?key=pk_your_key" | jq .brandColor
```

**JavaScript (browser or Node 18+)**

```js
const KEY = 'pk_your_key'
const logo = (domain, opts = '') => `https://img.favi.sh/${domain}?key=${KEY}${opts}`

img.src = logo('stripe.com', '&size=64')

const { results } = await fetch(`https://img.favi.sh/search?q=stri&key=${KEY}`).then((r) => r.json())
const brand = await fetch(`https://img.favi.sh/brand/stripe.com?key=${KEY}`).then((r) => r.json())
```

**React**

```jsx
// <link rel="preconnect" href="https://img.favi.sh"> in <head> saves a round trip.
export function Logo({ domain, size = 32, ...rest }) {
  const [failed, setFailed] = useState(false)
  if (failed) return <Initials name={domain} size={size} />
  return (
    <img
      src={`https://img.favi.sh/${domain}?key=pk_your_key&size=${size}`}
      width={size} height={size} alt="" loading="lazy"
      onError={() => setFailed(true)} {...rest}
    />
  )
}
```

The `onError` branch almost never runs — rejected, over-quota and unknown domains still return
an image — but it covers a network failure for free.

**Python**

```python
import requests

KEY = "pk_your_key"
r = requests.get(f"https://img.favi.sh/stripe.com", params={"key": KEY, "format": "png", "size": 128})
print(r.status_code, r.headers["x-favi-result"])   # 200 hit
open("stripe.png", "wb").write(r.content)

brand = requests.get(f"https://img.favi.sh/brand/stripe.com", params={"key": KEY}).json()
```

**Email / server-side / PDF**: no hotlink rule. Fetch from a server, cache it, embed it anywhere.

## Keys and allowed domains

Keys are publishable (`pk_…`), unlimited on every plan; quota is per account. The control that
matters is each key's domain allowlist: the key works only when the request's Referer is one of
your sites (subdomains included; `localhost` always allowed for development). A request from
elsewhere still returns an image — a lettermark, `x-favi-result: rejected:referer` — so a
misconfigured allowlist never shows users a broken image; it shows up as a rising rejected count
on the dashboard.

For server-to-server use where no Referer is sent, leave the allowlist empty on that key.

A key as `GET /api/keys` (or the MCP's `list_keys`) returns it: `id`, `key`, `label`,
`allowedDomains`, `status` (`active` or `revoked`), `monthlyQuota`, `createdAt`,
`lastUsedAt`. `monthlyQuota` is the plan allowance stamped on the key when it was minted and
re-stamped on every plan change; the quota is still enforced per account, across all its keys,
against the plan — the number on the key is the same number, shown per key. `null` means
unmetered, which only favi's own demo and probe keys are. `lastUsedAt` is updated once per
metering flush (a few seconds), not per request.

`GET /api/usage?days=30` returns `daily` (one row per key per day: `day`, `keyId`,
`requests`, `cacheHits`, `lettermarks`, `rejected`), `month` (the same totals for the
calendar month plus `lettermarkRatio`), `plan` (`name`, `monthlyRequests`) and the raw
Stripe `entitlement`. Everything is a number; the API never counts in strings.

## Misses and caching

A never-seen domain gets a lettermark immediately and a background crawl; the real logo usually
replaces it within seconds. Misses cache 30 seconds, real logos 30 days at the edge. Domains with
no usable icon (none, or nothing above 8 px) keep serving a lettermark.

## Refreshing a logo

Every domain is re-checked roughly every 60 days. Sooner, signed in:

```
POST https://favi.sh/api/refresh
{ "domain": "stripe.com" }
```

Re-crawls and purges the edge cache; 20 per hour per account.

## Agents and MCP

A coding agent can do everything the dashboard does — keys, allowlists, refreshes, usage — with one credential: an ordinary favi session token from the RFC 8628 device flow, sent
as a bearer. Revoke it by signing that session out:

```bash
curl -s -X POST https://favi.sh/api/auth/sign-out -H "Authorization: Bearer $TOKEN" \\
  -H 'content-type: application/json' -d '{}'     # 200; the token is dead from here
```

**MCP server:** `https://favi.sh/mcp` — Streamable HTTP, stateless, JSON replies, OAuth 2.1 per the MCP
authorization spec (dynamic client registration, PKCE; discovery at
`https://favi.sh/.well-known/oauth-authorization-server` and `https://favi.sh/.well-known/oauth-protected-resource`).
Add it and sign in when the client asks; the client keeps its own token. No keys to paste.

```bash
claude mcp add --transport http favi https://favi.sh/mcp
# then inside Claude Code: /mcp → favi → Authenticate (opens the browser once)
```

Tools: `logo_url`, `search_brands`, `get_brand`, `whoami`, `list_keys`, `create_key`,
`update_key`, `revoke_key`, `usage`, `refresh_domain`. Each is a thin face on the HTTP route
of the same name: same audit log, same rate limits, same roles. An unauthenticated request gets
`401` with `WWW-Authenticate: Bearer resource_metadata=…`, which is how a client knows to sign in.

**Plain HTTP**, for scripts and clients without OAuth (the device flow; the token it yields works
on `/mcp` too, as `Authorization: Bearer`):

```bash
curl -s -X POST https://favi.sh/api/auth/device/code -H 'content-type: application/json' \
  -d '{"client_id":"my-script"}'
# → { user_code, device_code, verification_uri_complete, expires_in, interval }
# The account owner approves at https://favi.sh/device?user_code=… ; poll every `interval` seconds:
curl -s -X POST https://favi.sh/api/auth/device/token -H 'content-type: application/json' \
  -d '{"grant_type":"urn:ietf:params:oauth:grant-type:device_code","device_code":"…","client_id":"my-script"}'
# → { access_token, token_type: "Bearer" }   (or { error: "authorization_pending" } until approved)

curl -s https://favi.sh/api/keys -H "Authorization: Bearer $TOKEN"
curl -s -X POST https://favi.sh/api/keys -H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \
  -d '{"label":"prod","allowedDomains":["example.com"]}'
curl -s -X PATCH https://favi.sh/api/keys/<id> -H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \
  -d '{"allowedDomains":["example.com","staging.example.com"]}'
curl -s -X DELETE https://favi.sh/api/keys/<id> -H "Authorization: Bearer $TOKEN"      # revoke
curl -s "https://favi.sh/api/usage?days=30" -H "Authorization: Bearer $TOKEN"
curl -s -X POST https://favi.sh/api/refresh -H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \
  -d '{"domain":"stripe.com"}'
```

Creating and changing keys needs the admin role on the account; the rest any member. Responses
are JSON; `401` means the token is missing or its session was revoked, `403` the role is too low.

## Limits and plans

| | Free | Pro |
|---|---|---|
| Price | $0 | $29/month |
| Requests / month (per account) | 500,000 | 2,000,000 |
| Keys | unlimited | unlimited |
| Brand data, search, ticker, name | included | included |
| Attribution required | no | no |
| Over quota | lettermarks, never errors | lettermarks, never errors |

Two smaller limits protect the crawler: 500 new (never-seen) domains per account per day (across
all its keys, like the quota), and 6,000 origin requests per minute per key (the edge cache keeps normal traffic far below it). Above
either, a lettermark. The account owner is emailed at 80% and 100% of quota.

## Migrating from another provider

| From | Their URL | favi URL |
|---|---|---|
| logo.dev | `https://img.logo.dev/{domain}?token=…&format=png` | `https://img.favi.sh/{domain}?key=…&format=png` (same parameter names; `token` accepted) |
| Brandfetch | `https://cdn.brandfetch.io/{domain}/w/64/h/64?c=…` | `https://img.favi.sh/{domain}?key=…&width=64&height=64` |
| Clearbit (shut down Dec 2025) | `https://logo.clearbit.com/{domain}` | `https://img.favi.sh/{domain}?key=…` |

The index already covers the domains real applications ask for, so the cutover is the URL
change; keep any existing `onError` fallbacks.

## Changelog

- **2026-09-09** — Pre-warming (`POST /api/warm`, the Migrate page) removed. The index already
  holds the domains real applications ask for, and a miss fills in within seconds of the first
  request, so a bulk pre-crawl was a step with nothing behind it. A large first import still
  fills in on its own; the 500-new-domains-a-day allowance (Limits and plans) is the only pacing.
  `POST /api/refresh` is unchanged.
- **2026-09-10** — A homepage that redirects to a different site no longer hands that site's name
  and logo to the requesting domain (a typo-squat of stripe.com was being served Stripe's mark).
  Search returns one entry per brand instead of one per domain sharing its logo. Usage rows are
  camelCase numbers; `lastUsedAt` on keys is now written; `plan` added to `/api/usage`.

## Links

- Docs (HTML): https://favi.sh/docs · this file: https://favi.sh/docs.md (also https://favi.sh/llms-full.txt)
- OpenAPI 3.1: https://favi.sh/openapi.json
- Short summary for LLMs: https://favi.sh/llms.txt
- MCP server: https://favi.sh/mcp (see Agents and MCP above)
- Status (externally measured) and SLA: https://favi.sh/status · https://favi.sh/legal/sla
- Comparisons: https://favi.sh/compare · https://favi.sh/logo-dev-alternative · https://favi.sh/brandfetch-alternative · https://favi.sh/clearbit-alternative
- Takedown / abuse: https://favi.sh/legal/takedown · abuse@favi.sh
