# Brand intelligence

> The canonical contract for authenticated Brand Intelligence routes and the anonymous aggregate brand read.

Source page: https://docs.replynodes.com/docs/guides/brand-intelligence

# Brand intelligence

The repository README contains the owned GitHub core entry point into this
loop, with bounded `source=github`, `utm_medium=owned`, and
`utm_campaign=core` attribution.

This page is the canonical public contract for Brand Intelligence, including
the v0.1.1 external npm CLI aggregate read. It describes two intentionally
different surfaces:

- The authenticated, metered `GET /v1/brand/retrieve`,
  `GET /v1/brand/search`, `GET /v1/brand/logo`, `GET /v1/brand/fonts`, and
  `GET /v1/brand/styleguide` routes on `api.replynodes.com`.
- The one anonymous aggregate read on the dedicated host:
  `GET https://brand.replynodes.com/{bare-domain}`.

The canonical OpenAPI contract remains
[`services/replynodes-fetcher/internal/gateway/capabilities.openapi.json`](https://github.com/replynodes/replynodes-fetcher/blob/main/services/replynodes-fetcher/internal/gateway/capabilities.openapi.json)
for the `/v1` routes: it is the source of their paths, parameters, schemas,
security, and standard `{data, meta}` response envelope. The dedicated-host
aggregate is intentionally not an anonymous `/v1` route and is specified here
because it has a separate path grammar, direct response object, quota, and
cache policy. `GET /v1/webcontext/brand` remains a separate authenticated,
metered operation governed by OpenAPI. None of these `/v1` routes is anonymous;
do not infer anonymous access to any `/v1/brand/*` route from the aggregate
contract.

## Brand Kit CLI

The approved v0.1.1 npm release target is the scoped package
`@replynodes/brand-kit`. Run it without an install using:

```sh
npx @replynodes/brand-kit <bare-domain>
```

The installed executable remains `brand-kit`; the unscoped `brand-kit` package name was blocked by
npm similarity policy because it was too similar to the existing `brandkit`
package. The GitHub repository remains `replynodes/brand-kit`, and the runtime
endpoint remains `GET https://brand.replynodes.com/{bare-domain}`. No API key or
signup is required, and the CLI preserves one request and read-only behavior.
The old unpublished GitHub v0.1.0 release/tag is historical. The approved
v0.1.1 package is published and current; a separate v0.1.2 keyword patch is
also published.

## Anonymous aggregate endpoint

The v0.1.1 CLI uses exactly this endpoint:

```text
GET https://brand.replynodes.com/{bare-domain}
```

It requires no account and no API key. The path value is one bare, public DNS
domain. An optional single trailing slash is accepted. The query string is
ignored, so it must not be used for versioning or options.

Normalization is deterministic: trim surrounding whitespace, lowercase, strip
one leading `www.`, and strip one trailing DNS dot. Embedded whitespace and
control characters are rejected rather than trimmed. The resulting hostname
must be an ASCII hostname of at least two dot-separated labels and no more than
253 characters total; each label is 1–63 characters, starts and ends with an
ASCII letter or digit, and permits hyphens only in the interior. Normalization
is used for the upstream lookup and cache key.

Accepted examples:

```text
/stripe.com
/WWW.Stripe.com/
/stripe.com.
/stripe.com?anything=ignored
```

Rejected examples (all return `400`):

```text
/https://stripe.com          # scheme
/stripe.com:443              # port
/stripe.com/docs             # path
/@user:password@stripe.com   # credentials
/localhost                   # local name
/127.0.0.1                   # IP literal
/169.254.169.254             # metadata/private address
```

Canonical and non-canonical IP spellings, cloud-metadata names, malformed
labels, and whitespace/control characters are rejected before an upstream
brand fetch. The resolution-level SSRF guard validates the initial DNS
resolution and rejects a non-public address. The pinned runtime then performs
its own separate fetch/resolution, so a residual DNS-rebinding TOCTOU risk
remains; this guard is not absolute protection against rebinding.

## Successful response

The response is a direct JSON object with
`Content-Type: application/json; charset=utf-8`. It is not wrapped in
`data`. Unknown keys must be ignored by clients.

The exact top-level keys are:

| Key | Type | Presence and meaning |
| --- | --- | --- |
| `domain` | string | Always present; normalized lookup domain. |
| `url` | string | Always present; canonical target URL used for extraction. |
| `name` | string | Optional detected brand/site name. |
| `description` | string | Optional public description. |
| `favicon` | string | Optional favicon URL. |
| `og_image` | string | Optional Open Graph/image URL. |
| `primary_logo` | string | Optional primary logo URL or inline asset value. |
| `logos` | array of objects | Optional logo assets. Each item has `url` (string) and may have `kind` (string). |
| `colors` | array of objects | Optional colors. Each item has `hex` (string), and may have `usage` (string) and `count` (integer). |
| `fonts` | array of strings | Optional detected font families. |
| `social_links` | array of strings | Optional links from supported social hosts. |
| `backdrops` | array of objects | Optional hero/banner/cover assets, with the same item shape as `logos`. |
| `styleguide` | object | Optional merged styleguide; see below. |
| `meta` | object | Always present; aggregate response metadata. |

Absent fields are normal. An absent field means the public source did not
provide a usable value; it is not an error. Clients must not assume that an
empty logo, color, font, social-link, backdrop, or styleguide collection is
available, and should generate local outputs around whatever subset is
present.

`meta` has these keys and types:

| Key | Type | Meaning |
| --- | --- | --- |
| `domain` | string | Same normalized domain as the top-level `domain`. |
| `cached` | boolean | `false` for a fresh aggregate build; `true` when served from the shared cache. |
| `fetched_at` | string | UTC RFC 3339 timestamp for the cached/fresh aggregate build. |
| `cache_ttl_seconds` | integer | `86400` for successful aggregate responses. |
| `source` | string | `brand-intelligence`. |
| `docs` | string | Canonical documentation URL. |

### Styleguide

When styleguide extraction succeeds, `styleguide` is an object containing
`domain` (string), `url` (string), `status` (string), and any available
`mode`, `colors`, `typography`, `buttons`, `card`, `spacing`, `shadows`, and
`radii` fields. Those nested collections and properties are optional. The
`status` values are:

- `extracted`: usable CSS/style blocks and design signals were extracted.
- `partial`: some style signals were found, but the result is incomplete.
- `unavailable`: the page was reachable but no usable styleguide signals were
  available.

If the separate styleguide fetch itself fails, the aggregate still returns
the successfully retrieved brand fields and omits the `styleguide` key. A
styleguide failure does not turn an otherwise successful brand aggregate into
an error. A present `styleguide` with `status: "unavailable"` is a valid
successful result and is different from a failed styleguide fetch.

### Asset kinds and values

Every `logos[].kind` and `backdrops[].kind` is normalized from an extraction
slot and an asset format. When the slot is non-empty and differs from the
format, the result is `<slot>.<format>`; when the slot is empty, the format is
returned alone; and when the slot already equals the format, the slot-only
value is returned (for example, `svg`). For HTTP(S) URLs, the format is taken
from the URL path and is one of `ico`, `png`, `svg`, `jpg`, `jpeg`, `webp`,
`avif`, `gif`, or the fallback `image`. Non-HTTP(S) values, including inline
assets, use the `inline` format. Thus different files from the same slot
remain distinguishable, such as `favicon.svg`, `favicon.ico`, and
`apple-touch-icon.png`, while slot/format combinations follow the rules above.

Treat non-HTTP(S) values as opaque data/references: do not fetch them as URLs.
Only fetch or download asset values beginning with `http://` or `https://`;
preserve other values for a later local rendering decision.

## HTTP behavior and errors

The aggregate supports `GET` and `HEAD`. `HEAD` performs the same contract
checks and reports the `Content-Length` of the corresponding `GET` JSON
representation, but sends no response body. Successful JSON responses include
`Cache-Control: public, max-age=86400`, and `Access-Control-Allow-Origin: *`.

Other methods return `405` with `Allow: GET, HEAD`. Errors use the standard
object envelope:

```json
{
  "error": {
    "code": "invalid_request",
    "message": "The request is malformed or contains invalid parameters.",
    "request_id": "..."
  }
}
```

The contract-level statuses are:

| Status | Code | Exact message |
| --- | --- | --- |
| `400` | `invalid_request` | `The request is malformed or contains invalid parameters.` |
| `405` | `method_not_allowed` | `This endpoint only supports GET requests.` |
| `404` | `not_found` | `The requested resource was not found.` |
| `429` | `rate_limited` | `The rate limit for this workspace or API key has been exhausted.` |
| `502` | `upstream_unavailable` | `The data provider is temporarily unavailable.` |
| `503` | `degraded` | `Authorization is temporarily unavailable; try again shortly.` |
| `504` | `gateway_timeout` | `The request could not be completed within the allowed time.` |

The `request_id` value is deployment-generated and should be retained for
support or diagnostics. `HEAD` sends no response body; clients should use the
status and headers.
The `404 not_found` case means the dedicated route is unavailable or not
configured on that deployment; it does not indicate invalid input.

## Cache, quota, and failure behavior

The anonymous quota is the shared capability-aware Tier A quota for the
canonical `brand-kit` capability: a fixed **20 requests per UTC calendar day
per trusted client-IP bucket**. The bucket identifier is derived from the
trusted client IP, the canonical capability, and the UTC date, and it lives in
the shared Redis counter namespace — the same engine and storage used by the
anonymous Markdown route, not a separate provider-specific minute counter.
Domain validation runs before admission, so a malformed or rejected request is
answered with `400` and never consumes a quota unit. Once admitted, a cache hit
and an upstream failure each consume exactly one daily unit. Successful
aggregate responses are stored in the shared Redis cache for 24 hours.
`X-Cache: miss` identifies a fresh build and `X-Cache: hit` identifies a cached
response.

Responses expose:

```text
X-RateLimit-Limit: 20
X-RateLimit-Remaining: <integer remaining in the current UTC day>
X-RateLimit-Reset: <Unix timestamp of the next UTC midnight>
```

When the quota is exhausted, the response is `429` and includes
`Retry-After` in seconds until the next UTC midnight.

A separate, generic per-IP **burst safeguard** of 20 requests per minute also
applies to every brand-host request, anonymous or keyed. It is not product
quota: it never consults or decrements the `brand-kit` capability bucket, it is
not metered or billed, and when it trips the route returns the generic
`429 rate_limited` (with `Retry-After`), never `anonymous_limit_reached`. It
absorbs abusive bursts, including from keyed callers who are not subject to the
anonymous daily quota; the `X-RateLimit-*` headers describe the product quota,
not this generic safeguard.

Missing, unavailable, or failed Redis is fail-closed: the route returns
`503 degraded` rather than
using a process-local limiter, bypassing the cache, or serving an uncached
anonymous upstream request. Cache read and write failures are also degraded
responses.

The gateway’s shipped default outer handler timeout is 15 seconds. Normally,
the configured 15-second handler timeout yields a 12-second brand-fetch
budget. Otherwise, subtract 3 seconds from the configured handler timeout,
apply a 5-second floor, then cap the result at the outer handler timeout, so
configured outer handler timeouts below 5 seconds yield that outer timeout. A
non-positive configuration falls back to the shipped 15-second default. The
subsequent cache `SET` uses `context.WithoutCancel` with no
separate deadline, so it is not guaranteed to finish before the outer
deadline; an outer handler timeout can still yield `504 gateway_timeout`.
Upstream failure maps to `502 upstream_unavailable`; a fetch or request
deadline maps to `504 gateway_timeout`; and cache read or write failure maps
to `503 degraded`. SSRF and public-address checks apply before upstream
extraction, subject to the residual DNS-rebinding risk described above. No
provider credentials, cookies, sessions, or write operation are part of this
endpoint.

## Verification and evidence

Production verification can use safe, read-only requests:

```sh
curl -sS -D /tmp/brand.headers \
  -o /tmp/brand.json \
  'https://brand.replynodes.com/stripe.com'
curl -sS -I 'https://brand.replynodes.com/stripe.com'
curl -sS -D - -o /dev/null \
  'https://brand.replynodes.com/stripe.com?probe=ignored'
```

Confirm the status, JSON content type, `domain`/`url`/`meta`, cache and rate
limit headers, and that the `HEAD` response has no body while its
`Content-Length` matches the corresponding GET representation. A second GET
for the same normalized domain should be a cache hit when the cache is warm.
Do not send API keys or other secrets. When recording evidence, redact client
IP details, cookies, authorization material, internal hostnames, and any
unbounded request or response metadata; retain status, relevant public
headers, request ID, and a redacted response shape.

This repository currently defines the production hostname
`brand.replynodes.com`; it does **not** define a public staging or testnet
brand hostname. Do not invent one. For a staging/testnet deployment, an
operator must first provide the deployment’s explicitly configured public
brand host, then run the same safe checks against that host and record the
configured host and redacted evidence. Internal service addresses are not
public CLI endpoints.

## Compatibility and CLI boundary

The external npm CLI v0.1 should treat this direct object as an additive,
forward-compatible contract: tolerate omitted optional fields and unknown
future fields, and require only `domain`, `url`, and `meta` for a structurally
valid success. Additive fields do not require a new CLI endpoint or a new
versioned path. There is no query-based API versioning and no API-key mode for
the aggregate endpoint.

After fetching, CLI outputs are generated locally. These are CLI concerns, not
additional API response contracts:

```text
brand.json
colors.json
fonts.json
logo references
tokens.css
DESIGN.md
```

## Non-goals

- No anonymous `/v1` brand routes.
- No account or API key for the aggregate read.
- No new aggregation backend service or endpoint.
- No change to the authenticated/metered `/v1/brand/retrieve`,
  `/v1/brand/search`, `/v1/brand/logo`, `/v1/brand/fonts`, or
  `/v1/brand/styleguide` routes.
- No server-side generation of the npm CLI’s local artifacts.

## Cross-discovery and returning-caller CTA

Successful anonymous aggregate responses include
`X-ReplyNodes-Discovery: markdown` and a `Link` relation to
`https://md.replynodes.com/{domain}`. A valid returning
`rn_anon_caller_id` cookie also exposes `X-ReplyNodes-CTA: api-key` and a
`Link` relation to `https://platform.replynodes.com/sign-up`. These are
informational response headers; no signup or API key is required before the
free read.
