# Cypher Public API — OpenAPI 3.1
# Public REST surface only (grouped user-facing tags + four x-cypher-extension compat paths).
# Path templates use Next.js segment names so the drift guard is string-equal
# after `[param]` → `{param}`. Foreplay's `{ad_id}` is mentioned in prose only.

openapi: "3.1.0"
info:
  title: Cypher Public API
  version: "0.1.0"
  license:
    name: Proprietary
    identifier: LicenseRef-Proprietary
  description: |
    ## Introduction

    Cypher is a competitive ad-intelligence API for searching ads and brands,
    analyzing creative patterns, and reading your saved library. The REST
    contract below is the public keyed surface; private session and admin
    routes are intentionally excluded.

    ## Base URL

    Production requests use `https://cypherapp.io`. The downloadable OpenAPI
    document and the explorer both default to that origin.

    ## Authentication

    Present a secret in **one header**. Query-string keys (`?api_key=`,
    `?token=`, `?secret=`) are rejected.

    Accepted presentations (first match wins):

    1. `x-secret-token: cypher_sk_…`
    2. `Authorization: Bearer cypher_sk_…`
    3. `Authorization: cypher_sk_…` (scheme-less, Foreplay-style)
    4. `x-api-key: cypher_sk_…` (or the system key)

    Mint a personal `cypher_sk_` key from
    [API keys and MCP setup](/api-mcp). Never commit a real key or paste one
    into a URL.

    ## API keys and scopes

    Three secret kinds are accepted:

    - Hashed `ApiKey` rows (`cypher_sk_` prefix). Scopes and monthly credits
      come from the row. Revoked → 403 `"API key has been revoked"`. Expired →
      403 `"API key has expired"`.
    - Legacy `User.apiSecretToken` (`cypher_sk_`). Default scopes
      `discovery` + `library` + `account`. No expiry.
    - `ADS_SYSTEM_API_KEY`. Scopes `discovery` + `account` only — **no
      `library`**, no bound user. Usage rows use the shared `system-key`
      bucket; `/api/usage` represents that bucket as `user.id = "system"`.

    Swipefile, Boards, and Spyder require a personal key bound to a user and
    the `library` scope. A system key (or any key without `library`) gets 403
    `"This endpoint requires a personal API key"` or `"Missing required scope:
    library"`.

    `x-required-scope` is the campaign contract (`discovery` / `library` /
    `account`). Discovery and analysis operations authenticate but do not
    currently call `hasScope(discovery)`; do not treat a missing 403 as a spec
    bug.

    ## First search in 90 seconds

    Check the current UTC month's balance before searching:

    ```bash
    curl -s https://cypherapp.io/api/usage \
      -H "Authorization: Bearer cypher_sk_YOUR_KEY"
    ```

    Then keep the first result set small. A successful response contains
    `data` and `metadata`; failures also include `error.message`.

    ```bash
    curl -s "https://cypherapp.io/api/discovery/ads?query=running%20shoes&limit=5" \
      -H "Authorization: Bearer cypher_sk_YOUR_KEY"
    ```

    Read `X-Credits-Remaining` and `X-Credit-Cost` after keyed responses and
    render every ad's `cypher_url` as a link.

    ## Competitor teardown

    Resolve a domain, pull ads and analytics, then expand duplicates of a
    winning creative:

    ```bash
    curl -s "https://cypherapp.io/api/brand/getBrandsByDomain?domain=example.com" \
      -H "Authorization: Bearer cypher_sk_YOUR_KEY"

    curl -s "https://cypherapp.io/api/brand/getAdsByBrandId?brand_ids=BRAND_CUID&limit=20" \
      -H "Authorization: Bearer cypher_sk_YOUR_KEY"

    curl -s "https://cypherapp.io/api/brand/analytics?id=BRAND_CUID" \
      -H "Authorization: Bearer cypher_sk_YOUR_KEY"

    curl -s "https://cypherapp.io/api/ad/duplicates/{adId}" \
      -H "Authorization: Bearer cypher_sk_YOUR_KEY"
    ```

    `GET /api/brand/getAdsByPageId?page_id=…` is the page-id alternative.
    `collect` is accepted and ignored; there is no on-demand scrape. Hold
    `limit` and `order` constant while paging.

    ## Your library over MCP

    A **personal** key is required for Swipefile, Boards, and Spyder. Hosted
    MCP exposes the same library helpers over Streamable HTTP:

    ```bash
    claude mcp add --transport http cypher https://cypherapp.io/api/mcp
    ```

    MCP is JSON-RPC, not a REST path in this document. REST fallbacks remain
    available with the same personal key:

    ```bash
    curl -s "https://cypherapp.io/api/swipefile/ads?limit=10" \
      -H "Authorization: Bearer cypher_sk_YOUR_KEY"

    curl -s https://cypherapp.io/api/boards \
      -H "Authorization: Bearer cypher_sk_YOUR_KEY"

    curl -s https://cypherapp.io/api/spyder/brands \
      -H "Authorization: Bearer cypher_sk_YOUR_KEY"
    ```

    ## Credits

    See `X-Credits-Remaining` / `X-Credit-Cost`:

    | Class | Cost |
    | --- | --- |
    | Ad list (`ad-list`) | 1 per ad returned |
    | Brand list (`brand-list`) | 1 per brand returned |
    | Ad lookup (`ad-lookup`) | 1 if an ad is found, else 0 |
    | Flat listing (`flat-listing`) | 1 if the payload is non-empty, else 0 |
    | Account (`/api/usage`) | free |
    | Receipt fetch (`/api/payments/{txId}/receipt`) | free |

    `PUBLIC_API_ENFORCE_CREDITS=true` turns a zero remaining balance into 402
    `"Out of credits"` before the handler runs. Default is off, but usage is
    still recorded. A 200 response is not an unlimited month. Credit headers
    are omitted when the remaining-credits read fails and on unauthenticated
    401/403 responses.

    ## x402

    x402 is Cypher's USDC pay-per-call rail on Base. It is a second way to
    buy a priced REST or MCP operation when monthly credits cannot cover
    the call. Who it is for: credit-exhausted `cypher_sk_` keys, and the
    flag-gated keyless discovery pilot. Covered callers (remaining credits
    > 0) **never** see `PAYMENT-*` headers or MCP `_meta` payment twins.

    The rail is live on Base mainnet when `X402_ENABLED=true` and
    facilitator config is present. `PUBLIC_API_ENFORCE_CREDITS=true` is
    what sends exhausted keyed callers into the 402 gate at all — flip it
    together with `X402_ENABLED`.

    HTTP 402 is dual-shape (D1):

    - **Challenge** — `PAYMENT-REQUIRED` header present (standard base64 (padded) x402 v2
      `PaymentRequired` with `accepts[0].scheme` always `"exact"`). Envelope
      message is additive (`Payment required: $X.XX for up to N items. See
      PAYMENT-REQUIRED header or https://cypherapp.io/developers#x402`).
      Retry the same request with `PAYMENT-SIGNATURE`. Success attaches
      `PAYMENT-RESPONSE`. MCP twins on `tools/call` (HTTP stays 200):
      `_meta["x402/payment-required"]`, `_meta["x402/payment"]`,
      `_meta["x402/payment-response"]`.
    - **Bare** — no `PAYMENT-REQUIRED`. Human-fixable: rail-off `"Out of
      credits"`, `"duplicate_settlement"` after a consumed payment nonce, or
      `"spend_limit_exceeded"` when a keyed caller is over their `ApiKey`
      spend ceiling (not payable; a human raises the limit on `/api-mcp`).

    Quote model: `credits × $0.002` (2000 micro-USD per credit) with a 1¢
    floor. Paying buys **up to** the clamped `limit`; fewer results are
    not refunded; there are no partial refunds. Control cost with `limit`.

    | Call | Credits | USD |
    | --- | --- | --- |
    | Ad lookup (`ad-lookup`) | 1 | $0.01 (floor) |
    | 5-ad search | 5 | $0.01 (floor) |
    | 25-ad search | 25 | $0.05 |
    | 100-brand explore | 100 | $0.20 |
    | 250-ad search | 250 | $0.50 |

    Networks: production is Base mainnet `eip155:8453`, asset USDC
    `0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913`, EIP-712 name `"USD Coin"`
    version `"2"`. Base Sepolia `eip155:84532` is non-production (EIP-712
    name `"USDC"`). Unset `X402_NETWORK` never defaults to mainnet.

    x402 payments settle as USDC transfers on Base and are irreversible. Cypher does not automatically refund. If you believe you were charged in error, contact support with the on-chain transaction hash (`tx_hash` from `PAYMENT-RESPONSE` or your receipt) and, if you sent one, the payment identifier. Eligible refunds are processed as a manual reverse USDC transfer from the Cypher treasury.

    Kill switches (string `'true'` only; any one off reverts its lane):
    `X402_ENABLED`, `X402_KEYLESS_ENABLED`, `PUBLIC_API_ENFORCE_CREDITS`.

    Optional **keyless pilot** (default off): when `X402_KEYLESS_ENABLED=true`
    and the rail is mounted, unauthenticated GETs on the OD-3 discovery set
    (`/api/discovery/ads`, `/api/discovery/brands`,
    `/api/discovery/brands/explore`, `/api/ad`, `/api/ad/{adId}`,
    `/api/ad/duplicates/{adId}`, `/api/v1/brands`) receive a payable 402
    instead of 401. Effective `limit` is capped at 100. Library paths stay
    401. The lane is independently disableable. Enable keyless only after
    the keyed mainnet loop passes. Facilitator OFAC/KYT screens payer and
    recipient.

    Settled `PAYMENT-RESPONSE` may include `extensions.receipt` — a compact
    ES256 JWT over the `PaymentTransaction` (`tx_id`, `operation`, `surface`,
    `usd_cents`, `amount_atomic`, `asset`, `network`, `tx_hash`,
    `payer_address`, optional `api_key_id`, `trace_id`, `iat`). Verify
    offline against
    `https://cypherapp.io/developers/receipt-public-key.pem`. v1 has no
    `kid`; rotating the keypair invalidates old JWTs against the new PEM.
    The on-chain `tx_hash` remains settlement proof. Keyless and system-key
    rows (`userId` / `apiKeyId` null) are **inline-only** — they cannot
    `GET /api/payments/{txId}/receipt`.

    CDP Bazaar indexes a metadata-bearing route ~10–15 minutes after the
    **first settled payment**. There is no delist. `resource.description`
    is the copy Bazaar displays. Payable 402s on the OD-3 REST set and
    five MCP twins declare `extensions.bazaar` (`info.input` /
    `info.output` / `schema`) so agents can construct a valid call.
    `resource.description` remains the display copy from
    `challenge-copy.ts`. Buyers echo `extensions.bazaar` on the payment
    payload (`PAYMENT-SIGNATURE` / MCP `_meta["x402/payment"]`); CDP
    catalogs from the settle payload, not from validate alone.

    See https://cypherapp.io/developers#x402.

    ## Pagination

    Hold `limit` and `order` constant across cursor pages.
    A cursor encodes its order, so reusing it with a different `order` is
    422. Offset endpoints use `offset` + `limit` instead.

    ## Errors

    Errors use the public envelope with `metadata.success: false`, an
    `error.message`, and `data: []`. Client-fixable problems are 4xx. On 5xx,
    surface `X-Trace-ID`; Cloudflare can replace the JSON body.

    Common statuses are 401 invalid/missing key, 402 out of credits, 403
    revoked/expired/insufficient scope, 404 unknown resource, 422 invalid
    input or cursor, 429 rate limited, and 500 internal error.

    ## Rate limits

    The limit is 60 requests per 60 seconds per key. Read `X-RateLimit-*` and
    honor `Retry-After` on 429.

    ## Media and cypher_url

    Every public Ad DTO includes a `cypher_url` pointing at the Discovery UI.
    Render it as a **markdown link**; do not invent an alternate path.

    `thumbnail`, `video`, and `image` are source URLs. This API
    never proxies media bytes.

    ## MCP and CLI

    Hosted MCP is `POST https://cypherapp.io/api/mcp` using Streamable HTTP
    and the same authentication headers. The CLI is `@cypherai/cli`, a thin
    HTTP client over the REST paths in this document. See the
    [Agent Skill](/skills/cypher-api/SKILL.md) for an agent-first operating
    guide.

    ## Contract notes

    Session/admin UI routes, `/api/mcp`, OPTIONS preflight, and POST/DELETE on
    `/api/boards` are not REST operations in this public spec. Path templates
    use the Next.js names `{adId}` and `{brandId}`. `x-cypher-extension: true`
    marks the four Cypher-only compatibility paths.

servers:
  - url: https://cypherapp.io
    description: Production

tags:
  - name: Ad Discovery
    description: Search the ad corpus and rank ads by estimated impressions.
  - name: Brand Discovery
    description: Search and explore brands by identity and recent ad activity.
  - name: Ads
    description: Resolve individual ads and related creative duplicates.
  - name: Brands
    description: Resolve a brand's ads, domains, page ids, and daily analytics.
  - name: Cypher Extensions
    description: Cypher-specific compatibility endpoints for legacy v1 brand consumers.
  - name: Swipefile
    description: Read ads saved by the authenticated user. Requires a personal key with `library`.
  - name: Boards
    description: Read owned boards, their ads, and their represented brands. Requires `library`.
  - name: Spyder
    description: Read personal tracked brands. Untracked is 403 `"User not subscribed"`, never 404.
  - name: Usage
    description: Inspect credit usage for the current UTC calendar month.
  - name: Payments
    description: Signed x402 receipts for settled PaymentTransaction rows.

x-tagGroups:
  - name: Discover
    tags: [Ad Discovery, Brand Discovery]
  - name: Analyze
    tags: [Ads, Brands, Cypher Extensions]
  - name: Your Library
    tags: [Swipefile, Boards, Spyder]
  - name: Account
    tags: [Usage, Payments]

security:
  - ApiKeyHeader: []
  - SecretTokenHeader: []
  - BearerAuth: []
  - RawAuthorization: []

paths:
  "/api/discovery/ads":
    get:
      tags: [Ad Discovery]
      operationId: getDiscoveryAds
      summary: Search the ad corpus
      x-required-scope: discovery
      description: |
        Full ad filter matrix + keyset cursor. `limit` default 20, max 250.
        `most_relevant` uses `ts_rank` when `query` is present; otherwise it
        falls back to `newest`.

        **Credits:** 1 per ad returned (`ad-list`).
      parameters:
        - $ref: "#/components/parameters/Query"
        - $ref: "#/components/parameters/QueryAlias"
        - $ref: "#/components/parameters/Live"
        - $ref: "#/components/parameters/DisplayFormat"
        - $ref: "#/components/parameters/DisplayFormatAlias"
        - $ref: "#/components/parameters/PublisherPlatform"
        - $ref: "#/components/parameters/PublisherPlatformAlias"
        - $ref: "#/components/parameters/PublisherPlatformsAlias"
        - $ref: "#/components/parameters/Niches"
        - $ref: "#/components/parameters/NicheAlias"
        - $ref: "#/components/parameters/Languages"
        - $ref: "#/components/parameters/LanguageAlias"
        - $ref: "#/components/parameters/MarketTarget"
        - $ref: "#/components/parameters/VideoDurationMin"
        - $ref: "#/components/parameters/VideoDurationMax"
        - $ref: "#/components/parameters/RunningDurationMinDays"
        - $ref: "#/components/parameters/RunningDurationMaxDays"
        - $ref: "#/components/parameters/StartDate"
        - $ref: "#/components/parameters/EndDate"
        - $ref: "#/components/parameters/Country"
        - $ref: "#/components/parameters/AdOrder"
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/LimitDefault20Max250"
      responses:
        "200":
          $ref: "#/components/responses/AdList"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/PaymentRequired"
        "403":
          $ref: "#/components/responses/Forbidden"
        "422":
          $ref: "#/components/responses/Unprocessable"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"

  "/api/discovery/brands":
    get:
      tags: [Brand Discovery]
      operationId: getDiscoveryBrands
      summary: Search brands by name or domain
      x-required-scope: discovery
      description: |
        Single-page search. `query` / `q` / `domain` is required (422 if
        missing). `limit` default 10, max 100. Honest `metadata.total_found`.

        **Credits:** 1 per brand returned (`brand-list`).
      parameters:
        - $ref: "#/components/parameters/Query"
        - $ref: "#/components/parameters/QueryAlias"
        - name: domain
          in: query
          description: Alias for `query`.
          schema:
            type: string
        - $ref: "#/components/parameters/LimitDefault10Max100"
      responses:
        "200":
          $ref: "#/components/responses/BrandList"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/PaymentRequired"
        "403":
          $ref: "#/components/responses/Forbidden"
        "422":
          $ref: "#/components/responses/Unprocessable"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"

  "/api/discovery/brands/explore":
    get:
      tags: [Brand Discovery]
      operationId: getDiscoveryBrandsExplore
      summary: Explore brands by recent ad activity
      x-required-scope: discovery
      description: |
        Same ad filter matrix as `/api/discovery/ads`. `days` 1–365 (default
        30). `limit` default 20, max 10000. Ranked by measured ad count.

        **Credits:** 1 per brand returned (`brand-list`).
      parameters:
        - $ref: "#/components/parameters/Query"
        - $ref: "#/components/parameters/QueryAlias"
        - $ref: "#/components/parameters/Live"
        - $ref: "#/components/parameters/DisplayFormat"
        - $ref: "#/components/parameters/DisplayFormatAlias"
        - $ref: "#/components/parameters/PublisherPlatform"
        - $ref: "#/components/parameters/PublisherPlatformAlias"
        - $ref: "#/components/parameters/PublisherPlatformsAlias"
        - $ref: "#/components/parameters/Niches"
        - $ref: "#/components/parameters/NicheAlias"
        - $ref: "#/components/parameters/Languages"
        - $ref: "#/components/parameters/LanguageAlias"
        - $ref: "#/components/parameters/MarketTarget"
        - $ref: "#/components/parameters/VideoDurationMin"
        - $ref: "#/components/parameters/VideoDurationMax"
        - $ref: "#/components/parameters/RunningDurationMinDays"
        - $ref: "#/components/parameters/RunningDurationMaxDays"
        - $ref: "#/components/parameters/StartDate"
        - $ref: "#/components/parameters/EndDate"
        - $ref: "#/components/parameters/Country"
        - name: days
          in: query
          description: Lookback window in days (1–365). Default 30.
          schema:
            type: integer
            minimum: 1
            maximum: 365
            default: 30
        - $ref: "#/components/parameters/LimitDefault20Max10000"
      responses:
        "200":
          $ref: "#/components/responses/BrandList"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/PaymentRequired"
        "403":
          $ref: "#/components/responses/Forbidden"
        "422":
          $ref: "#/components/responses/Unprocessable"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"

  "/api/discovery/ads/impressions":
    get:
      tags: [Ad Discovery]
      operationId: getDiscoveryAdsImpressions
      summary: Ads sorted by impressions
      x-required-scope: discovery
      x-cypher-extension: true
      description: |
        Cypher compat extension. Ads sorted by impressions (high to low).
        Params: `brand`/`q`, `platform`, `min_days`. `limit` default 20, max 100.

        **Credits:** 1 per ad returned (`ad-list`).
      parameters:
        - name: brand
          in: query
          description: Brand name or keyword. Alias `q`.
          schema:
            type: string
        - $ref: "#/components/parameters/QueryAlias"
        - name: platform
          in: query
          description: Filter by platform (`facebook`, `instagram`, `tiktok`).
          schema:
            type: string
        - name: min_days
          in: query
          description: Only include ads running at least this many days.
          schema:
            type: integer
            minimum: 0
            default: 0
        - $ref: "#/components/parameters/LimitDefault20Max100"
      responses:
        "200":
          $ref: "#/components/responses/AdList"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/PaymentRequired"
        "403":
          $ref: "#/components/responses/Forbidden"
        "422":
          $ref: "#/components/responses/Unprocessable"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"

  "/api/discovery/ads/impressions/by-brand":
    get:
      tags: [Ad Discovery]
      operationId: getDiscoveryAdsImpressionsByBrand
      summary: Ads for one brand, sorted by impressions
      x-required-scope: discovery
      x-cypher-extension: true
      description: |
        Cypher compat extension. `brand`/`q` is required → **400** if missing.
        `sort` ∈ `impressions` / `newest` / `oldest` / `longest`. `limit`
        default 20, max 100 + `offset`.

        **Credits:** 1 per ad returned (`ad-list`).
      parameters:
        - name: brand
          in: query
          description: Brand name (required). Alias `q`.
          schema:
            type: string
        - $ref: "#/components/parameters/QueryAlias"
        - name: sort
          in: query
          description: Sort order. Default `impressions`.
          schema:
            type: string
            enum: [impressions, newest, oldest, longest]
            default: impressions
        - name: platform
          in: query
          description: Filter by platform (`facebook`, `instagram`, `tiktok`, `google`).
          schema:
            type: string
        - name: active_only
          in: query
          description: When `true`, only return active ads.
          schema:
            type: boolean
        - $ref: "#/components/parameters/LimitDefault20Max100"
        - $ref: "#/components/parameters/Offset"
      responses:
        "200":
          $ref: "#/components/responses/AdList"
        "400":
          $ref: "#/components/responses/BadRequest"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/PaymentRequired"
        "403":
          $ref: "#/components/responses/Forbidden"
        "422":
          $ref: "#/components/responses/Unprocessable"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"

  "/api/ad":
    get:
      tags: [Ads]
      operationId: getAdByQuery
      summary: Get one ad by `ad_id`
      x-required-scope: discovery
      description: |
        Query form of get-by-id. `ad_id` only — there is no `id` alias.
        Resolves cuid → `externalId` (with or without `fb-`) → `foreplayId`.
        Missing/blank `ad_id` → 422. Unknown → 404.

        An ad whose cuid is literally `duplicates` is **only** reachable via
        this query form (the path `/api/ad/duplicates` is the duplicates
        collection).

        **Credits:** 1 if found (`ad-lookup`).
      parameters:
        - name: ad_id
          in: query
          required: true
          description: Ad cuid, Meta library id, or Foreplay id. No `id` alias.
          schema:
            type: string
      responses:
        "200":
          $ref: "#/components/responses/AdOne"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/PaymentRequired"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          $ref: "#/components/responses/Unprocessable"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"

  "/api/ad/{adId}":
    get:
      tags: [Ads]
      operationId: getAdById
      summary: Get one ad by path id
      x-required-scope: discovery
      description: |
        Path form of get-by-id. Same resolver, DTO, 404, and credit as
        `GET /api/ad?ad_id=`. Foreplay documents this as `{ad_id}` — that
        spelling is **not** registered as a second path.

        **Credits:** 1 if found (`ad-lookup`).
      parameters:
        - $ref: "#/components/parameters/AdIdPath"
      responses:
        "200":
          $ref: "#/components/responses/AdOne"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/PaymentRequired"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          $ref: "#/components/responses/Unprocessable"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"

  "/api/ad/duplicates/{adId}":
    get:
      tags: [Ads]
      operationId: getAdDuplicates
      summary: Other ads sharing the same media URL
      x-required-scope: discovery
      description: |
        Other ads sharing the anchor's non-null `mediaUrl` (exact string
        equality), excluding the anchor. Hard-capped at 100. Unknown
        anchor → 404. Anchor with no media → 200 empty
        (`data: []`, `metadata.count: 0`, `total_found: 0`).

        **Credits:** 1 per ad returned (`ad-list`).
      parameters:
        - $ref: "#/components/parameters/AdIdPath"
      responses:
        "200":
          $ref: "#/components/responses/AdList"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/PaymentRequired"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          $ref: "#/components/responses/Unprocessable"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"

  "/api/brand/getAdsByBrandId":
    get:
      tags: [Brands]
      operationId: getAdsByBrandId
      summary: Ads for one or more brand ids
      x-required-scope: discovery
      description: |
        `brand_ids` (comma or repeated; alias `brand_id`) are Brand cuids or
        `foreplayBrandId`s, max 50 unique. Missing → 422. Zero resolved
        names → 404. Full filter matrix + keyset cursor + `limit` ≤250.

        `collect` is accepted-and-ignored (no on-demand scrape). When the
        param is present, `metadata.collect_ignored` is `true`.

        **Credits:** 1 per ad returned (`ad-list`).
      parameters:
        - name: brand_ids
          in: query
          description: Brand cuid or `foreplayBrandId`. Repeatable or comma-separated. Max 50 unique. Alias `brand_id`.
          schema:
            type: array
            items:
              type: string
          style: form
          explode: true
        - name: brand_id
          in: query
          description: Alias for `brand_ids`.
          schema:
            type: string
        - name: collect
          in: query
          description: Accepted and ignored. Sets `metadata.collect_ignored` when present.
          schema:
            type: string
        - $ref: "#/components/parameters/Query"
        - $ref: "#/components/parameters/QueryAlias"
        - $ref: "#/components/parameters/Live"
        - $ref: "#/components/parameters/DisplayFormat"
        - $ref: "#/components/parameters/DisplayFormatAlias"
        - $ref: "#/components/parameters/PublisherPlatform"
        - $ref: "#/components/parameters/PublisherPlatformAlias"
        - $ref: "#/components/parameters/PublisherPlatformsAlias"
        - $ref: "#/components/parameters/Niches"
        - $ref: "#/components/parameters/NicheAlias"
        - $ref: "#/components/parameters/Languages"
        - $ref: "#/components/parameters/LanguageAlias"
        - $ref: "#/components/parameters/MarketTarget"
        - $ref: "#/components/parameters/VideoDurationMin"
        - $ref: "#/components/parameters/VideoDurationMax"
        - $ref: "#/components/parameters/RunningDurationMinDays"
        - $ref: "#/components/parameters/RunningDurationMaxDays"
        - $ref: "#/components/parameters/StartDate"
        - $ref: "#/components/parameters/EndDate"
        - $ref: "#/components/parameters/Country"
        - $ref: "#/components/parameters/AdOrder"
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/LimitDefault20Max250"
      responses:
        "200":
          $ref: "#/components/responses/AdList"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/PaymentRequired"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          $ref: "#/components/responses/Unprocessable"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"

  "/api/brand/getAdsByPageId":
    get:
      tags: [Brands]
      operationId: getAdsByPageId
      summary: Ads for a Meta page id
      x-required-scope: discovery
      description: |
        Numeric Meta `page_id` required (422 otherwise). Matches `Ad.brandId`
        or `Brand.pageId`. Unknown numeric page → **200 empty** (not 404).
        Same filters / cursor / limit as getAdsByBrandId.

        **Credits:** 1 per ad returned (`ad-list`).
      parameters:
        - name: page_id
          in: query
          required: true
          description: Numeric Meta page id.
          schema:
            type: string
            pattern: "^[0-9]+$"
        - $ref: "#/components/parameters/Query"
        - $ref: "#/components/parameters/QueryAlias"
        - $ref: "#/components/parameters/Live"
        - $ref: "#/components/parameters/DisplayFormat"
        - $ref: "#/components/parameters/DisplayFormatAlias"
        - $ref: "#/components/parameters/PublisherPlatform"
        - $ref: "#/components/parameters/PublisherPlatformAlias"
        - $ref: "#/components/parameters/PublisherPlatformsAlias"
        - $ref: "#/components/parameters/Niches"
        - $ref: "#/components/parameters/NicheAlias"
        - $ref: "#/components/parameters/Languages"
        - $ref: "#/components/parameters/LanguageAlias"
        - $ref: "#/components/parameters/MarketTarget"
        - $ref: "#/components/parameters/VideoDurationMin"
        - $ref: "#/components/parameters/VideoDurationMax"
        - $ref: "#/components/parameters/RunningDurationMinDays"
        - $ref: "#/components/parameters/RunningDurationMaxDays"
        - $ref: "#/components/parameters/StartDate"
        - $ref: "#/components/parameters/EndDate"
        - $ref: "#/components/parameters/Country"
        - $ref: "#/components/parameters/AdOrder"
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/LimitDefault20Max250"
      responses:
        "200":
          $ref: "#/components/responses/AdList"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/PaymentRequired"
        "403":
          $ref: "#/components/responses/Forbidden"
        "422":
          $ref: "#/components/responses/Unprocessable"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"

  "/api/brand/getBrandsByDomain":
    get:
      tags: [Brands]
      operationId: getBrandsByDomain
      summary: Brands matching a domain
      x-required-scope: discovery
      description: |
        `domain` (aliases `query` / `q`) is required → 422 if missing.
        Scheme / `www.` / path / port are stripped. `limit` default 10, max 10.
        Ranked by measured ad count descending. Matches `Brand.domain` and
        `websites` JSON — not `name` ILIKE.

        **Credits:** 1 per brand returned (`brand-list`).
      parameters:
        - name: domain
          in: query
          description: Hostname or URL. Aliases `query` / `q`.
          schema:
            type: string
        - $ref: "#/components/parameters/Query"
        - $ref: "#/components/parameters/QueryAlias"
        - $ref: "#/components/parameters/LimitDefault10Max10"
      responses:
        "200":
          $ref: "#/components/responses/BrandList"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/PaymentRequired"
        "403":
          $ref: "#/components/responses/Forbidden"
        "422":
          $ref: "#/components/responses/Unprocessable"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"

  "/api/brand/analytics":
    get:
      tags: [Brands]
      operationId: getBrandAnalytics
      summary: Per-day brand analytics
      x-required-scope: discovery
      description: |
        `id` is a Brand cuid or numeric page id (**not** `foreplayBrandId`).
        Unknown → 404. `start_date` / `end_date` are UTC date-only. Default
        window is the last 30 days; max 366 inclusive days.

        Each row is an active day with `ads_started`, running `live_count`,
        `format_mix`, and computed `runtime_buckets`. Computed from `Ad` —
        never `durationDays`, never `Brand.analytics`.

        **Credits:** 1 per row returned (`ad-list`). Empty is free.
      parameters:
        - name: id
          in: query
          required: true
          description: Brand cuid or numeric Meta page id. Not `foreplayBrandId`.
          schema:
            type: string
        - $ref: "#/components/parameters/StartDate"
        - $ref: "#/components/parameters/EndDate"
      responses:
        "200":
          $ref: "#/components/responses/BrandAnalyticsList"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/PaymentRequired"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          $ref: "#/components/responses/Unprocessable"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"

  "/api/v1/brands":
    get:
      tags: [Cypher Extensions]
      operationId: listV1Brands
      summary: List brands (legacy v1 shape)
      x-required-scope: discovery
      x-cypher-extension: true
      description: |
        Cypher compat extension. Envelope **superset** of `{brands, nextCursor}`
        (`data` + `metadata` are added). Opaque keyset cursor. Optional `q`.
        `limit` default 20, max 100. `brands` are raw Prisma Brand rows, not
        the Foreplay Brand DTO.

        **Credits:** 1 per brand returned (`brand-list`).
      parameters:
        - name: q
          in: query
          description: Optional case-insensitive name filter.
          schema:
            type: string
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/LimitDefault20Max100"
      responses:
        "200":
          $ref: "#/components/responses/V1BrandList"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/PaymentRequired"
        "403":
          $ref: "#/components/responses/Forbidden"
        "422":
          $ref: "#/components/responses/Unprocessable"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"

  "/api/v1/brands/{brandId}/ads":
    get:
      tags: [Cypher Extensions]
      operationId: listV1BrandAds
      summary: Ads for a brand (legacy v1 shape)
      x-required-scope: discovery
      x-cypher-extension: true
      description: |
        Cypher compat extension. Envelope **superset** of
        `{brandId, totalFound, ads}`. Matches `brandId` **or**
        case-insensitive `brandName`. `active=true|false`. `limit` default
        100, max 200 (`parseLimit`). `ads` are raw Prisma Ad rows, not the
        Foreplay Ad DTO.

        **Credits:** 1 per ad returned (`ad-list`).
      parameters:
        - name: brandId
          in: path
          required: true
          description: Brand cuid or brand name (case-insensitive).
          schema:
            type: string
        - name: active
          in: query
          description: When `true`/`false`, filter `isActive`. Omitted = all.
          schema:
            type: string
            enum: ["true", "false"]
        - name: limit
          in: query
          description: Page size. Default 100, max 200.
          schema:
            type: integer
            minimum: 1
            maximum: 200
            default: 100
      responses:
        "200":
          $ref: "#/components/responses/V1BrandAds"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/PaymentRequired"
        "403":
          $ref: "#/components/responses/Forbidden"
        "422":
          $ref: "#/components/responses/Unprocessable"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"

  "/api/swipefile/ads":
    get:
      tags: [Swipefile]
      operationId: getSwipefileAds
      summary: Saved ads for the authenticated user
      x-required-scope: library
      description: |
        Personal key required. Offset/limit (default 10, max 250) — **not**
        cursor. Orders `saved_newest` (default) / `newest` / `oldest` /
        `longest_running`. Accepts the ad filter matrix.

        **Credits:** 1 per ad returned (`ad-list`).
      parameters:
        - name: order
          in: query
          description: Swipefile sort. Default `saved_newest`.
          schema:
            type: string
            enum: [saved_newest, newest, oldest, longest_running]
            default: saved_newest
        - $ref: "#/components/parameters/Query"
        - $ref: "#/components/parameters/QueryAlias"
        - $ref: "#/components/parameters/Live"
        - $ref: "#/components/parameters/DisplayFormat"
        - $ref: "#/components/parameters/DisplayFormatAlias"
        - $ref: "#/components/parameters/PublisherPlatform"
        - $ref: "#/components/parameters/PublisherPlatformAlias"
        - $ref: "#/components/parameters/PublisherPlatformsAlias"
        - $ref: "#/components/parameters/Niches"
        - $ref: "#/components/parameters/NicheAlias"
        - $ref: "#/components/parameters/Languages"
        - $ref: "#/components/parameters/LanguageAlias"
        - $ref: "#/components/parameters/MarketTarget"
        - $ref: "#/components/parameters/VideoDurationMin"
        - $ref: "#/components/parameters/VideoDurationMax"
        - $ref: "#/components/parameters/RunningDurationMinDays"
        - $ref: "#/components/parameters/RunningDurationMaxDays"
        - $ref: "#/components/parameters/StartDate"
        - $ref: "#/components/parameters/EndDate"
        - $ref: "#/components/parameters/Country"
        - $ref: "#/components/parameters/Offset"
        - $ref: "#/components/parameters/LimitDefault10Max250"
      responses:
        "200":
          $ref: "#/components/responses/AdList"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/PaymentRequired"
        "403":
          $ref: "#/components/responses/Forbidden"
        "422":
          $ref: "#/components/responses/Unprocessable"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"

  "/api/boards":
    get:
      tags: [Boards]
      operationId: listBoards
      summary: List the authenticated user's boards
      x-required-scope: library
      description: |
        Personal key required. This documents the **keyed Foreplay envelope
        only** — the session GET (raw array) is not the public contract.
        Invalid presented secrets must not fall through to the session array.

        `limit` omitted = all boards. `folders=true` synthesizes a Default
        folder (`{folders:[{name:"Default", is_default:true, boards:[…]}]}`);
        offset/limit are ignored (`metadata.offset_limit_ignored`).

        **Credits:** 1 if the payload is non-empty (`flat-listing`).
      parameters:
        - name: folders
          in: query
          description: When `true`/`1`/`yes`, wrap boards in a synthesized Default folder.
          schema:
            type: string
        - $ref: "#/components/parameters/Offset"
        - name: limit
          in: query
          description: Page size. Omitted = all boards. Ignored when `folders=true`.
          schema:
            type: integer
            minimum: 1
            maximum: 250
      responses:
        "200":
          $ref: "#/components/responses/BoardList"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/PaymentRequired"
        "403":
          $ref: "#/components/responses/Forbidden"
        "422":
          $ref: "#/components/responses/Unprocessable"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"

  "/api/board/brands":
    get:
      tags: [Boards]
      operationId: getBoardBrands
      summary: Distinct brands of ads on an owned board
      x-required-scope: library
      description: |
        Personal key required. `board_id` required. Cross-tenant / unknown
        board → 404. `ads_count` is board-local. `limit` default 10, max 10.

        **Credits:** 1 if the payload is non-empty (`flat-listing`).
      parameters:
        - name: board_id
          in: query
          required: true
          description: Owned board cuid.
          schema:
            type: string
        - $ref: "#/components/parameters/Offset"
        - $ref: "#/components/parameters/LimitDefault10Max10"
      responses:
        "200":
          $ref: "#/components/responses/BrandList"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/PaymentRequired"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          $ref: "#/components/responses/Unprocessable"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"

  "/api/board/ads":
    get:
      tags: [Boards]
      operationId: getBoardAds
      summary: Ads on an owned board
      x-required-scope: library
      description: |
        Personal key required. `board_id` required → 422. Cross-tenant /
        unknown → 404 **before** the items query. Full filter matrix +
        keyset cursor. `limit` default 10, max 250.

        **Credits:** 1 per ad returned (`ad-list`).
      parameters:
        - name: board_id
          in: query
          required: true
          description: Owned board cuid.
          schema:
            type: string
        - $ref: "#/components/parameters/Query"
        - $ref: "#/components/parameters/QueryAlias"
        - $ref: "#/components/parameters/Live"
        - $ref: "#/components/parameters/DisplayFormat"
        - $ref: "#/components/parameters/DisplayFormatAlias"
        - $ref: "#/components/parameters/PublisherPlatform"
        - $ref: "#/components/parameters/PublisherPlatformAlias"
        - $ref: "#/components/parameters/PublisherPlatformsAlias"
        - $ref: "#/components/parameters/Niches"
        - $ref: "#/components/parameters/NicheAlias"
        - $ref: "#/components/parameters/Languages"
        - $ref: "#/components/parameters/LanguageAlias"
        - $ref: "#/components/parameters/MarketTarget"
        - $ref: "#/components/parameters/VideoDurationMin"
        - $ref: "#/components/parameters/VideoDurationMax"
        - $ref: "#/components/parameters/RunningDurationMinDays"
        - $ref: "#/components/parameters/RunningDurationMaxDays"
        - $ref: "#/components/parameters/StartDate"
        - $ref: "#/components/parameters/EndDate"
        - $ref: "#/components/parameters/Country"
        - $ref: "#/components/parameters/AdOrder"
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/LimitDefault10Max250"
      responses:
        "200":
          $ref: "#/components/responses/AdList"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/PaymentRequired"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "422":
          $ref: "#/components/responses/Unprocessable"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"

  "/api/spyder/brands":
    get:
      tags: [Spyder]
      operationId: listSpyderBrands
      summary: Tracked brands for the authenticated user
      x-required-scope: library
      description: |
        Personal key required. Offset / `limit` default 10, max 10. Dedupes
        repeated `(userId, lower(brandName))`. Empty library → 200
        `{data:[], count:0}`.

        **Credits:** 1 if the payload is non-empty (`flat-listing`).
      parameters:
        - $ref: "#/components/parameters/Offset"
        - $ref: "#/components/parameters/LimitDefault10Max10"
      responses:
        "200":
          $ref: "#/components/responses/SpyderBrandList"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/PaymentRequired"
        "403":
          $ref: "#/components/responses/Forbidden"
        "422":
          $ref: "#/components/responses/Unprocessable"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"

  "/api/spyder/brand":
    get:
      tags: [Spyder]
      operationId: getSpyderBrand
      summary: One tracked brand
      x-required-scope: library
      description: |
        Personal key required. `brand_id` required → 422 (Brand cuid,
        `brandName`, or stored `TrackedBrand.brandId`). Untracked / other-user
        / unknown → **403 `"User not subscribed"`** (never 404). `data` is a
        **single object**.

        **Credits:** 1 (`flat-listing`).
      parameters:
        - name: brand_id
          in: query
          required: true
          description: Brand cuid, brand name, or stored tracked-brand id.
          schema:
            type: string
      responses:
        "200":
          $ref: "#/components/responses/SpyderBrandOne"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/PaymentRequired"
        "403":
          $ref: "#/components/responses/Forbidden"
        "422":
          $ref: "#/components/responses/Unprocessable"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"

  "/api/spyder/brand/ads":
    get:
      tags: [Spyder]
      operationId: getSpyderBrandAds
      summary: Ads for a tracked brand
      x-required-scope: library
      description: |
        Personal key required. Tracked-check first → 403 `"User not
        subscribed"` **before** the ads query. Then the full filter matrix +
        keyset cursor. `limit` default 10, max 250.

        **Credits:** 1 per ad returned (`ad-list`).
      parameters:
        - name: brand_id
          in: query
          required: true
          description: Brand cuid, brand name, or stored tracked-brand id.
          schema:
            type: string
        - $ref: "#/components/parameters/Query"
        - $ref: "#/components/parameters/QueryAlias"
        - $ref: "#/components/parameters/Live"
        - $ref: "#/components/parameters/DisplayFormat"
        - $ref: "#/components/parameters/DisplayFormatAlias"
        - $ref: "#/components/parameters/PublisherPlatform"
        - $ref: "#/components/parameters/PublisherPlatformAlias"
        - $ref: "#/components/parameters/PublisherPlatformsAlias"
        - $ref: "#/components/parameters/Niches"
        - $ref: "#/components/parameters/NicheAlias"
        - $ref: "#/components/parameters/Languages"
        - $ref: "#/components/parameters/LanguageAlias"
        - $ref: "#/components/parameters/MarketTarget"
        - $ref: "#/components/parameters/VideoDurationMin"
        - $ref: "#/components/parameters/VideoDurationMax"
        - $ref: "#/components/parameters/RunningDurationMinDays"
        - $ref: "#/components/parameters/RunningDurationMaxDays"
        - $ref: "#/components/parameters/StartDate"
        - $ref: "#/components/parameters/EndDate"
        - $ref: "#/components/parameters/Country"
        - $ref: "#/components/parameters/AdOrder"
        - $ref: "#/components/parameters/Cursor"
        - $ref: "#/components/parameters/LimitDefault10Max250"
      responses:
        "200":
          $ref: "#/components/responses/AdList"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/PaymentRequired"
        "403":
          $ref: "#/components/responses/Forbidden"
        "422":
          $ref: "#/components/responses/Unprocessable"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"

  "/api/usage":
    get:
      tags: [Usage]
      operationId: getUsage
      summary: Current calendar-month credit usage
      x-required-scope: account
      description: |
        Cost 0. Foreplay `UsagesResponse` keys at the **top level**
        (`start_date`, `end_date`, `total_credits`, `remaining_credits`,
        `user:{id,email}`) plus the envelope superset. UTC calendar month.
        System key: `user: {id:"system", email:null}`. Missing `account`
        scope → 403. Additive `payment.spend` reports the presenting key's
        x402 spend ceiling (`limit_usd_cents` / `window_days` null when
        unlimited). Credits remain free and are never challenged.

        **Credits:** free.
      responses:
        "200":
          description: Current month usage.
          headers: &SuccessResponseHeaders
            X-Trace-ID:
              $ref: "#/components/headers/X-Trace-ID"
            X-Process-Time:
              $ref: "#/components/headers/X-Process-Time"
            X-RateLimit-Limit:
              $ref: "#/components/headers/X-RateLimit-Limit"
            X-RateLimit-Remaining:
              $ref: "#/components/headers/X-RateLimit-Remaining"
            X-RateLimit-Reset:
              $ref: "#/components/headers/X-RateLimit-Reset"
            X-Credits-Remaining:
              $ref: "#/components/headers/X-Credits-Remaining"
            X-Credit-Cost:
              $ref: "#/components/headers/X-Credit-Cost"
            PAYMENT-RESPONSE:
              $ref: "#/components/headers/PAYMENT-RESPONSE"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/UsagesResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "402":
          $ref: "#/components/responses/PaymentRequired"
        "403":
          $ref: "#/components/responses/Forbidden"
        "422":
          $ref: "#/components/responses/Unprocessable"
        "429":
          $ref: "#/components/responses/RateLimited"
        "500":
          $ref: "#/components/responses/InternalError"

  "/api/payments/{txId}/receipt":
    get:
      tags: [Payments]
      operationId: getPaymentReceipt
      summary: Fetch a signed receipt for an owned settled payment
      x-required-scope: account
      description: |
        Re-sign and return `{receipt}` (compact ES256 JWT) for a
        `PaymentTransaction` the caller owns (`apiKeyId` or `userId` match).
        Cross-tenant, unknown, unsettled, keyless, and system-key rows
        (both ids null) → **404** `"Payment not found"` (never 403).
        Keyless / unauthenticated rows are **inline-only**
        (`PAYMENT-RESPONSE.extensions.receipt`); this GET 404s them.
        Missing `account` scope → 403 `"Missing required scope: account"`.
        Signer unavailable after ownership is proven → 503
        `"Receipt signing unavailable"`. Verify the JWT against
        `https://cypherapp.io/developers/receipt-public-key.pem`.

        **Credits:** free. Never challenged. Never charged.
      parameters:
        - name: txId
          in: path
          required: true
          description: PaymentTransaction cuid (`[txId]`).
          schema:
            type: string
      responses:
        "200":
          description: Owned settled payment receipt.
          headers: *SuccessResponseHeaders
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PaymentReceiptResponse"
        "401":
          $ref: "#/components/responses/Unauthorized"
        "403":
          $ref: "#/components/responses/Forbidden"
        "404":
          $ref: "#/components/responses/NotFound"
        "429":
          $ref: "#/components/responses/RateLimited"
        "503":
          $ref: "#/components/responses/ServiceUnavailable"

components:
  securitySchemes:
    ApiKeyHeader:
      type: apiKey
      in: header
      name: x-api-key
      description: API key or system key. Query-string keys are rejected.
    SecretTokenHeader:
      type: apiKey
      in: header
      name: x-secret-token
      description: Foreplay-style secret header. Query-string keys are rejected.
    BearerAuth:
      type: http
      scheme: bearer
      description: "`Authorization: Bearer cypher_sk_…`. Query-string keys are rejected."
    RawAuthorization:
      type: apiKey
      in: header
      name: Authorization
      description: Scheme-less raw `cypher_sk_…` in `Authorization` (Foreplay-style). Query-string keys are rejected.

  headers:
    X-Trace-ID:
      description: Request id (`req_<uuid>`).
      schema:
        type: string
    X-Process-Time:
      description: Handler duration in seconds, three decimal places.
      schema:
        type: string
    X-RateLimit-Limit:
      description: Window ceiling (60).
      schema:
        type: string
    X-RateLimit-Remaining:
      description: Requests left in the current window.
      schema:
        type: string
    X-RateLimit-Reset:
      description: Unix timestamp (seconds) when the window resets.
      schema:
        type: string
    Retry-After:
      description: Seconds to wait. Present on 429 only.
      schema:
        type: string
    X-Credits-Remaining:
      description: Allotment minus used minus this response's cost. Omitted when the remaining-credits read fails, and on unauthenticated 401/403.
      schema:
        type: string
    X-Credit-Cost:
      description: Credits billed for this response. Omitted with `X-Credits-Remaining`.
      schema:
        type: string
    PAYMENT-REQUIRED:
      description: |
        Standard base64 (padded) x402 v2 `PaymentRequired` JSON (`x402Version: 2`, `resource`,
        `accepts` with `scheme: "exact"`, `extensions.payment-identifier`,
        additive `extensions.bazaar` on OD-3 payable 402s).
        Present only on a payable 402 challenge. Absent on bare 402
        (`Out of credits`, `duplicate_settlement`, `spend_limit_exceeded`)
        and on every 2xx.
      schema:
        type: string
    PAYMENT-RESPONSE:
      description: |
        Standard base64 (padded) x402 v2 settlement receipt (`success`, `transaction`,
        `network`, `payer`). May include `extensions.receipt` (compact ES256
        JWT) when the receipt signer is configured. Present only on x402-paid
        2xx. Credits-covered callers never receive this header. Keyless
        buyers receive the JWT only here (inline-only; GET receipt 404s).
      schema:
        type: string

  parameters:
    AdIdPath:
      name: adId
      in: path
      required: true
      description: Ad cuid, Meta library id, or Foreplay id (Next.js `[adId]`).
      schema:
        type: string
    Query:
      name: query
      in: query
      description: Keyword search. Alias `q`.
      schema:
        type: string
    QueryAlias:
      name: q
      in: query
      description: Alias for `query`.
      schema:
        type: string
    Live:
      name: live
      in: query
      description: Filter by live status. Distinct `true` / `false`; omitted = all.
      schema:
        type: boolean
    DisplayFormat:
      name: display_format
      in: query
      description: Repeatable or comma-separated. Alias `format`.
      schema:
        type: array
        items:
          type: string
      style: form
      explode: true
    DisplayFormatAlias:
      name: format
      in: query
      description: Alias for `display_format`.
      schema:
        type: array
        items:
          type: string
      style: form
      explode: true
    PublisherPlatform:
      name: publisher_platform
      in: query
      description: Repeatable or comma-separated. Aliases `platform` / `platforms`.
      schema:
        type: array
        items:
          type: string
      style: form
      explode: true
    PublisherPlatformAlias:
      name: platform
      in: query
      description: Alias for `publisher_platform`.
      schema:
        type: array
        items:
          type: string
      style: form
      explode: true
    PublisherPlatformsAlias:
      name: platforms
      in: query
      description: Alias for `publisher_platform`.
      schema:
        type: array
        items:
          type: string
      style: form
      explode: true
    Niches:
      name: niches
      in: query
      description: Repeatable or comma-separated. Alias `niche`.
      schema:
        type: array
        items:
          type: string
      style: form
      explode: true
    NicheAlias:
      name: niche
      in: query
      description: Alias for `niches`.
      schema:
        type: array
        items:
          type: string
      style: form
      explode: true
    Languages:
      name: languages
      in: query
      description: Repeatable or comma-separated. Alias `language`.
      schema:
        type: array
        items:
          type: string
      style: form
      explode: true
    LanguageAlias:
      name: language
      in: query
      description: Alias for `languages`.
      schema:
        type: array
        items:
          type: string
      style: form
      explode: true
    MarketTarget:
      name: market_target
      in: query
      description: Repeatable or comma-separated.
      schema:
        type: array
        items:
          type: string
      style: form
      explode: true
    VideoDurationMin:
      name: video_duration_min
      in: query
      schema:
        type: number
    VideoDurationMax:
      name: video_duration_max
      in: query
      schema:
        type: number
    RunningDurationMinDays:
      name: running_duration_min_days
      in: query
      schema:
        type: number
    RunningDurationMaxDays:
      name: running_duration_max_days
      in: query
      schema:
        type: number
    StartDate:
      name: start_date
      in: query
      description: UTC date-only (`YYYY-MM-DD`). Inclusive start.
      schema:
        type: string
        format: date
    EndDate:
      name: end_date
      in: query
      description: UTC date-only (`YYYY-MM-DD`). Inclusive end.
      schema:
        type: string
        format: date
    Country:
      name: country
      in: query
      description: Market country. Default `US`.
      schema:
        type: string
        default: US
    AdOrder:
      name: order
      in: query
      description: Keyset order. `most_relevant` is `ts_rank` when `query` is present; otherwise falls back to `newest`.
      schema:
        type: string
        enum: [newest, oldest, longest_running, most_relevant]
        default: newest
    Cursor:
      name: cursor
      in: query
      description: Opaque keyset cursor from the previous page. Hold `limit` (and `order`) constant.
      schema:
        type: string
    Offset:
      name: offset
      in: query
      description: Offset pagination start. Default 0.
      schema:
        type: integer
        minimum: 0
        default: 0
    LimitDefault20Max250:
      name: limit
      in: query
      description: Page size. Default 20, max 250.
      schema:
        type: integer
        minimum: 1
        maximum: 250
        default: 20
    LimitDefault10Max250:
      name: limit
      in: query
      description: Page size. Default 10, max 250.
      schema:
        type: integer
        minimum: 1
        maximum: 250
        default: 10
    LimitDefault20Max100:
      name: limit
      in: query
      description: Page size. Default 20, max 100.
      schema:
        type: integer
        minimum: 1
        maximum: 100
        default: 20
    LimitDefault10Max100:
      name: limit
      in: query
      description: Page size. Default 10, max 100.
      schema:
        type: integer
        minimum: 1
        maximum: 100
        default: 10
    LimitDefault10Max10:
      name: limit
      in: query
      description: Page size. Default 10, max 10.
      schema:
        type: integer
        minimum: 1
        maximum: 10
        default: 10
    LimitDefault20Max10000:
      name: limit
      in: query
      description: Page size. Default 20, max 10000.
      schema:
        type: integer
        minimum: 1
        maximum: 10000
        default: 20

  schemas:
    SuccessMetadata:
      type: object
      required: [success, status_code, processed_at]
      properties:
        success:
          type: boolean
          const: true
        status_code:
          type: integer
          const: 200
        processed_at:
          type: integer
          description: Unix epoch milliseconds.
        cursor:
          type: [string, "null"]
        count:
          type: integer
        total_found:
          type: integer
        filters: {}
        order:
          type: string

    PaginatedMetadata:
      allOf:
        - $ref: "#/components/schemas/SuccessMetadata"
        - type: object
          properties:
            cursor:
              type: [string, "null"]
            count:
              type: integer
            total_found:
              type: integer

    ErrorMetadata:
      type: object
      required: [success, message, status_code, processed_at]
      properties:
        success:
          type: boolean
          const: false
        message:
          type: string
        status_code:
          type: integer
        processed_at:
          type: integer
          description: Unix epoch milliseconds.

    ErrorBody:
      type: object
      required: [metadata, error, data]
      properties:
        metadata:
          $ref: "#/components/schemas/ErrorMetadata"
        error:
          type: object
          required: [message]
          properties:
            message:
              type: string
            detail:
              type: object
              additionalProperties: true
              description: Additive structured detail (e.g. `spend_limit_exceeded` budget numbers).
        data:
          type: array
          items: {}
          description: Always an empty array on errors.

    Ad:
      type: object
      required:
        - ad_id
        - avatar
        - cards
        - content_filter
        - cta_title
        - cta_type
        - cypher_url
        - description
        - display_format
        - emotional_drivers
        - full_transcription
        - headline
        - id
        - image
        - languages
        - likes
        - link_url
        - live
        - market_target
        - name
        - niches
        - persona
        - product_category
        - publisher_platform
        - running_duration
        - started_running
        - thumbnail
        - timestamped_transcription
        - video
        - video_duration
      additionalProperties: false
      properties:
        ad_id:
          type: [string, "null"]
        avatar:
          type: [string, "null"]
        cards:
          description: Creative cards payload as stored. Shape is not contracted.
        content_filter:
          description: Content-filter payload as stored. Shape is not contracted.
        cta_title:
          type: [string, "null"]
        cta_type:
          type: [string, "null"]
        cypher_url:
          type: string
          description: Discovery UI URL. Render as a markdown link.
        description:
          type: [string, "null"]
        display_format:
          type: [string, "null"]
        emotional_drivers:
          description: Emotional-drivers payload as stored. Shape is not contracted.
        full_transcription:
          type: [string, "null"]
        headline:
          type: [string, "null"]
        id:
          type: string
        image:
          type: [string, "null"]
          description: Source image URL. Media bytes are never proxied.
        languages:
          type: [array, "null"]
          items:
            type: string
        likes:
          type: [integer, "null"]
        link_url:
          type: [string, "null"]
        live:
          type: boolean
        market_target:
          type: [string, "null"]
        name:
          type: [string, "null"]
        niches:
          type: [array, "null"]
          items:
            type: string
        persona:
          description: Persona payload as stored. Shape is not contracted.
        product_category:
          type: [string, "null"]
        publisher_platform:
          type: [array, "null"]
          items:
            type: string
        running_duration:
          type: [integer, "null"]
          description: Running duration in whole days.
        started_running:
          type: [integer, "null"]
          description: Start time as Unix epoch milliseconds.
        thumbnail:
          type: [string, "null"]
          description: Source thumbnail URL. Media bytes are never proxied.
        timestamped_transcription:
          description: Timestamped transcription payload as stored. Shape is not contracted.
        video:
          type: [string, "null"]
          description: Source video URL. Media bytes are never proxied.
        video_duration:
          type: [number, "null"]

    Brand:
      type: object
      required:
        - ads_count
        - avatar
        - category
        - description
        - domain
        - id
        - industry
        - name
        - niches
        - page_id
        - publisher_platform
        - verification_status
        - website_url
      additionalProperties: false
      properties:
        ads_count:
          type: integer
        avatar:
          type: [string, "null"]
        category:
          type: [string, "null"]
        description:
          type: [string, "null"]
        domain:
          type: [string, "null"]
        id:
          type: string
        industry:
          type: [string, "null"]
        name:
          type: string
        niches:
          type: [array, "null"]
          items:
            type: string
        page_id:
          type: [string, "null"]
        publisher_platform:
          type: [array, "null"]
          items:
            type: string
        verification_status:
          type: [string, "null"]
        website_url:
          type: [string, "null"]

    BrandAnalyticsRow:
      type: object
      required: [ads_started, date, format_mix, live_count, runtime_buckets]
      additionalProperties: false
      properties:
        ads_started:
          type: integer
        date:
          type: string
          format: date
        format_mix:
          type: object
          required: [image, video, other]
          additionalProperties: false
          properties:
            image:
              type: integer
            video:
              type: integer
            other:
              type: integer
        live_count:
          type: integer
        runtime_buckets:
          type: object
          required: [lt7, d7to30, d30to90, d90plus]
          additionalProperties: false
          properties:
            lt7:
              type: integer
            d7to30:
              type: integer
            d30to90:
              type: integer
            d90plus:
              type: integer

    Board:
      type: object
      required: [ads_count, created_at, description, id, name, updated_at]
      additionalProperties: false
      properties:
        ads_count:
          type: integer
        created_at:
          type: string
          format: date-time
        description:
          type: [string, "null"]
        id:
          type: string
        name:
          type: string
        updated_at:
          type: string
          format: date-time

    BoardFolder:
      type: object
      required: [boards, is_default, name]
      additionalProperties: false
      properties:
        boards:
          type: array
          items:
            $ref: "#/components/schemas/Board"
        is_default:
          type: boolean
        name:
          type: string

    SpyderBrand:
      allOf:
        - $ref: "#/components/schemas/Brand"
        - type: object
          required: [last_checked]
          properties:
            last_checked:
              type: [string, "null"]
              format: date-time

    UsagesResponse:
      type: object
      required:
        - start_date
        - end_date
        - total_credits
        - remaining_credits
        - user
        - data
        - metadata
      properties:
        start_date:
          type: string
          format: date
        end_date:
          type: string
          format: date
        total_credits:
          type: integer
        remaining_credits:
          type: integer
        user:
          type: object
          required: [id]
          properties:
            id:
              type: string
              description: User cuid, or `"system"` for `ADS_SYSTEM_API_KEY`.
            email:
              type: [string, "null"]
        payment:
          type: object
          description: Additive OD-5 block. Not a Foreplay-required key.
          properties:
            spend:
              type: object
              required: [limit_usd_cents, window_days, spent_usd_cents, remaining_usd_cents]
              properties:
                limit_usd_cents:
                  type: [integer, "null"]
                  description: Per-key x402 spend ceiling in USD cents. `null` = unlimited.
                window_days:
                  type: [integer, "null"]
                  description: Rolling window in days (`null` when unlimited; default 30 when a limit is set).
                spent_usd_cents:
                  type: integer
                remaining_usd_cents:
                  type: [integer, "null"]
        data: {}
        metadata:
          $ref: "#/components/schemas/SuccessMetadata"

    PaymentReceiptResponse:
      type: object
      required: [receipt, data, metadata]
      properties:
        receipt:
          type: string
          description: Compact ES256 JWT over the settled PaymentTransaction.
        data:
          type: object
          required: [receipt]
          properties:
            receipt:
              type: string
        metadata:
          $ref: "#/components/schemas/SuccessMetadata"

    AdListEnvelope:
      type: object
      required: [data, metadata]
      properties:
        data:
          type: array
          items:
            $ref: "#/components/schemas/Ad"
        metadata:
          $ref: "#/components/schemas/PaginatedMetadata"

    AdOneEnvelope:
      type: object
      required: [data, metadata]
      properties:
        data:
          $ref: "#/components/schemas/Ad"
        metadata:
          $ref: "#/components/schemas/SuccessMetadata"

    BrandListEnvelope:
      type: object
      required: [data, metadata]
      properties:
        data:
          type: array
          items:
            $ref: "#/components/schemas/Brand"
        metadata:
          $ref: "#/components/schemas/PaginatedMetadata"

    BrandAnalyticsEnvelope:
      type: object
      required: [data, metadata]
      properties:
        data:
          type: array
          items:
            $ref: "#/components/schemas/BrandAnalyticsRow"
        metadata:
          $ref: "#/components/schemas/PaginatedMetadata"

    BoardListEnvelope:
      type: object
      required: [data, metadata]
      properties:
        data:
          oneOf:
            - type: array
              items:
                $ref: "#/components/schemas/Board"
            - type: object
              required: [folders]
              properties:
                folders:
                  type: array
                  items:
                    $ref: "#/components/schemas/BoardFolder"
        metadata:
          $ref: "#/components/schemas/PaginatedMetadata"

    SpyderBrandListEnvelope:
      type: object
      required: [data, metadata]
      properties:
        data:
          type: array
          items:
            $ref: "#/components/schemas/SpyderBrand"
        metadata:
          $ref: "#/components/schemas/PaginatedMetadata"

    SpyderBrandOneEnvelope:
      type: object
      required: [data, metadata]
      properties:
        data:
          $ref: "#/components/schemas/SpyderBrand"
        metadata:
          $ref: "#/components/schemas/SuccessMetadata"

    V1BrandListEnvelope:
      type: object
      required: [brands, data, metadata]
      properties:
        brands:
          type: array
          items: {}
          description: Raw Prisma Brand rows (legacy shape).
        nextCursor:
          type: [string, "null"]
        data: {}
        metadata:
          $ref: "#/components/schemas/PaginatedMetadata"

    V1BrandAdsEnvelope:
      type: object
      required: [brandId, totalFound, ads, data, metadata]
      properties:
        brandId:
          type: string
        totalFound:
          type: integer
        ads:
          type: array
          items: {}
          description: Raw Prisma Ad rows (legacy shape).
        data: {}
        metadata:
          $ref: "#/components/schemas/PaginatedMetadata"

  responses:
    AdList:
      description: Paginated Ad DTO list.
      headers: *SuccessResponseHeaders
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/AdListEnvelope"
    AdOne:
      description: Single Ad DTO.
      headers: *SuccessResponseHeaders
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/AdOneEnvelope"
    BrandList:
      description: Paginated Brand DTO list.
      headers: *SuccessResponseHeaders
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/BrandListEnvelope"
    BrandAnalyticsList:
      description: Per-day analytics rows.
      headers: *SuccessResponseHeaders
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/BrandAnalyticsEnvelope"
    BoardList:
      description: Keyed Foreplay board list (or synthesized folders).
      headers: *SuccessResponseHeaders
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/BoardListEnvelope"
    SpyderBrandList:
      description: Tracked-brand list.
      headers: *SuccessResponseHeaders
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/SpyderBrandListEnvelope"
    SpyderBrandOne:
      description: Single tracked brand (`data` is an object).
      headers: *SuccessResponseHeaders
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/SpyderBrandOneEnvelope"
    V1BrandList:
      description: Legacy `{brands, nextCursor}` plus envelope.
      headers: *SuccessResponseHeaders
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/V1BrandListEnvelope"
    V1BrandAds:
      description: Legacy `{brandId, totalFound, ads}` plus envelope.
      headers: *SuccessResponseHeaders
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/V1BrandAdsEnvelope"
    Unauthorized:
      description: Missing or unknown API key.
      headers: &UnauthenticatedHeaders
        X-Trace-ID:
          $ref: "#/components/headers/X-Trace-ID"
        X-Process-Time:
          $ref: "#/components/headers/X-Process-Time"
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorBody"
    Forbidden:
      description: Revoked / expired key, missing scope, personal-key-required, or untracked spyder brand.
      headers: *UnauthenticatedHeaders
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorBody"
    PaymentRequired:
      description: |
        Dual-shape 402. When the x402 rail is mounted and the keyed caller's
        monthly credits are exhausted, the body is an additive payment message
        and `PAYMENT-REQUIRED` carries a standard base64 (padded) x402 v2 challenge
        (`accepts[0].scheme` is always `"exact"`; payable OD-3 challenges include
        additive `extensions.bazaar`). When the rail is off or
        unconfigured this is a bare 402 `"Out of credits"` with no payment
        header. `"duplicate_settlement"` is also a bare 402 (not payable).
        `"spend_limit_exceeded"` is a bare 402 when a keyed caller is over
        their `ApiKey` spend ceiling (not payable; no `PAYMENT-REQUIRED`; a
        human raises the limit on `/api-mcp`). Credits-covered callers never
        receive 402 from this gate.
      headers:
        X-Trace-ID:
          $ref: "#/components/headers/X-Trace-ID"
        X-Process-Time:
          $ref: "#/components/headers/X-Process-Time"
        X-RateLimit-Limit:
          $ref: "#/components/headers/X-RateLimit-Limit"
        X-RateLimit-Remaining:
          $ref: "#/components/headers/X-RateLimit-Remaining"
        X-RateLimit-Reset:
          $ref: "#/components/headers/X-RateLimit-Reset"
        X-Credits-Remaining:
          $ref: "#/components/headers/X-Credits-Remaining"
        X-Credit-Cost:
          $ref: "#/components/headers/X-Credit-Cost"
        PAYMENT-REQUIRED:
          $ref: "#/components/headers/PAYMENT-REQUIRED"
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorBody"
    BadRequest:
      description: Handler-level 400 (e.g. missing `brand` on impressions-by-brand).
      headers: *SuccessResponseHeaders
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorBody"
    NotFound:
      description: Unknown ad, brand, board, or payment (cross-tenant receipts 404, never 403).
      headers: *SuccessResponseHeaders
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorBody"
    ServiceUnavailable:
      description: Receipt signing unavailable (signer sentinel after ownership is proven).
      headers: *SuccessResponseHeaders
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorBody"
    Unprocessable:
      description: Validation error (missing required param, invalid cursor, bad date window).
      headers: *SuccessResponseHeaders
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorBody"
    RateLimited:
      description: Rate limit exceeded (60 requests / 60 seconds).
      headers:
        X-Trace-ID:
          $ref: "#/components/headers/X-Trace-ID"
        X-Process-Time:
          $ref: "#/components/headers/X-Process-Time"
        X-RateLimit-Limit:
          $ref: "#/components/headers/X-RateLimit-Limit"
        X-RateLimit-Remaining:
          $ref: "#/components/headers/X-RateLimit-Remaining"
        X-RateLimit-Reset:
          $ref: "#/components/headers/X-RateLimit-Reset"
        Retry-After:
          $ref: "#/components/headers/Retry-After"
        X-Credits-Remaining:
          $ref: "#/components/headers/X-Credits-Remaining"
        X-Credit-Cost:
          $ref: "#/components/headers/X-Credit-Cost"
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorBody"
    InternalError:
      description: Unexpected server error.
      headers: *SuccessResponseHeaders
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/ErrorBody"
