openapi: 3.1.0
info:
  title: Bot Hound API
  version: "1"
  description: |
    Programmatic bot detection for X (Twitter) accounts. Submit a username, get
    a bot probability and verdict back. Checks are async — a `POST` returns an
    ID, you poll the matching `GET` until `status` is `completed` or `failed`.

    **Pricing:** $0.40 per scan. Batches of 11+ usernames are billed at
    $0.32 per account instead — the discount applies to the whole batch, not
    just the accounts past the 10th. Cache hits are billed at full price.

    **Billing:** this API only draws down your prepaid Bot Hound balance —
    there's no separate invoicing or card-on-file for API usage. Top up at
    https://bot-hound.com/home.

    **Refunds:** a check that terminally fails — the account doesn't exist, is
    protected, or we can't classify it — is credited back to your balance
    automatically, at the same per-check price it was charged. There's no
    refund request to file. Failures are always scoped to the individual
    check, never to the request that submitted it: in a batch, the accounts
    that succeeded keep their results and stay billed, and only the failed
    members are refunded. Nothing that fails at *submission* time stays
    charged either — see each endpoint's error responses.

    Get your API key and see your usage at https://bot-hound.com/api — that's
    also where the key you send as a Bearer token comes from.
servers:
  - url: https://api.bot-hound.com
security:
  - bearerAuth: []
tags:
  - name: Bot Checks
    description: Submit and poll single and batch bot checks.
  - name: Account
    description: Prepaid balance and usage.
paths:
  /api/v1/bot-checks:
    post:
      operationId: createCheck
      tags: [Bot Checks]
      summary: Submit a single bot check
      description: >-
        Resolves the username on X before charging — an account that doesn't
        exist or is protected costs nothing. On success, deducts $0.40 from
        your balance and queues the check. A check that fails *after* it's
        queued is refunded automatically; poll the check and read `refunded`.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [username]
              properties:
                username:
                  type: string
                  description: >-
                    An X handle, with or without a leading `@`. Must be 1-15
                    characters, letters/digits/underscore only.
                  example: suspicious_handle
            example:
              username: suspicious_handle
      responses:
        '202':
          description: Check accepted and queued. Poll `GET /api/v1/bot-checks/{id}` for the result.
          headers:
            X-Request-Id: { $ref: '#/components/headers/XRequestId' }
            X-RateLimit-Limit: { $ref: '#/components/headers/XRateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/XRateLimitRemaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/XRateLimitReset' }
          content:
            application/json:
              schema:
                type: object
                required: [check_id, username, status, cost_cents, balance_cents]
                properties:
                  check_id:
                    type: string
                    format: uuid
                    description: Poll this ID at `GET /api/v1/bot-checks/{id}`.
                  username: { type: string }
                  status: { type: string, enum: [processing] }
                  cost_cents: { type: integer, description: "What this check was billed, in cents." }
                  balance_cents: { type: integer, description: Your remaining balance after this charge. }
              example:
                check_id: 3fa85f64-5717-4562-b3fc-2c963f66afa6
                username: suspicious_handle
                status: processing
                cost_cents: 40
                balance_cents: 1420
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/InsufficientBalance' }
        '404': { $ref: '#/components/responses/TargetNotFound' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/SubmissionInternalError' }
        '502': { $ref: '#/components/responses/LookupFailed' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }
  /api/v1/bot-checks/{id}:
    get:
      operationId: getCheck
      tags: [Bot Checks]
      summary: Get a single check's result
      description: Owner-scoped — a check ID belonging to another account returns `404`.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
          description: The `check_id` returned by `POST /api/v1/bot-checks`.
      responses:
        '200':
          description: The check, in whatever state it's currently in.
          headers:
            X-Request-Id: { $ref: '#/components/headers/XRequestId' }
            X-RateLimit-Limit: { $ref: '#/components/headers/XRateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/XRateLimitRemaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/XRateLimitReset' }
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CheckResult'
              examples:
                completed:
                  summary: A completed check
                  value:
                    check_id: 3fa85f64-5717-4562-b3fc-2c963f66afa6
                    username: suspicious_handle
                    status: completed
                    bot_probability: 0.87
                    verdict: Very likely a bot
                    reasoning:
                      - Account created in the last 30 days
                      - No profile photo
                      - High post frequency with no engagement
                    cached: false
                    checked_at: "2026-08-09T18:00:00Z"
                    cost_cents: 40
                failed:
                  summary: A failed check (auto-refunded)
                  value:
                    check_id: 5c1e2a10-8b3d-4f2a-9e6c-1234567890ab
                    username: doesnotexist12345
                    status: failed
                    error:
                      code: account_not_found
                      message: account not found on X
                    refunded: true
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
  /api/v1/bot-checks/batch:
    post:
      operationId: createBatch
      tags: [Bot Checks]
      summary: Submit a batch of bot checks
      description: |
        2–1,000 usernames per request, deduped case-insensitively before
        pricing and counting. No X lookup happens at submission — the whole
        batch is charged up front, then each username is resolved and checked
        in the background. The discount (see `info.description`, batches of
        11+) is decided by the accepted count, i.e. after dedup.

        Failure handling splits cleanly at the `202`:

        **Anything other than a `202`** rejects the batch as a whole. Nothing
        stays charged, no `batch_id` is created, and there's no partial batch
        to poll — retry the whole request.

        **After a `202`**, failures are per-account and never discard work
        that already succeeded. An account that can't be resolved, is
        protected, or can't be classified fails its own member check and is
        refunded at `cost_per_check_cents`; every other account in the batch
        still completes and stays billed. The batch itself never fails — it
        reaches `status: completed` with a non-zero `progress.failed` and a
        matching `refunded_cents`.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [usernames]
              properties:
                usernames:
                  type: array
                  minItems: 2
                  maxItems: 1000
                  items:
                    type: string
                  description: >-
                    2–1,000 entries after removing duplicates (case-insensitive).
                    Leading `@` accepted.
              example:
                usernames:
                  - alice
                  - bob
                  - carol
                  - dave
                  - erin
                  - frank
                  - grace
                  - heidi
                  - ivan
                  - judy
                  - mallory
      responses:
        '202':
          description: Batch accepted and queued. Poll `GET /api/v1/bot-checks/batch/{id}` for progress.
          headers:
            X-Request-Id: { $ref: '#/components/headers/XRequestId' }
            X-RateLimit-Limit: { $ref: '#/components/headers/XRateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/XRateLimitRemaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/XRateLimitReset' }
          content:
            application/json:
              schema:
                type: object
                required: [batch_id, status, total, cost_per_check_cents, cost_cents, balance_cents]
                properties:
                  batch_id:
                    type: string
                    format: uuid
                    description: Poll this ID at `GET /api/v1/bot-checks/batch/{id}`.
                  status: { type: string, enum: [processing] }
                  total: { type: integer, description: Accepted usernames after dedup. }
                  cost_per_check_cents: { type: integer, description: Per-account price actually applied to this batch. }
                  cost_cents: { type: integer, description: "total * cost_per_check_cents." }
                  balance_cents: { type: integer, description: Your remaining balance after this charge. }
              example:
                batch_id: b6e4f0aa-9c2a-4f36-8e7b-1a2b3c4d5e6f
                status: processing
                total: 11
                cost_per_check_cents: 32
                cost_cents: 352
                balance_cents: 4296
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/InsufficientBalance' }
        '409': { $ref: '#/components/responses/Conflict' }
        '422': { $ref: '#/components/responses/ValidationError' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/SubmissionInternalError' }
        '503': { $ref: '#/components/responses/ServiceUnavailable' }
  /api/v1/bot-checks/batch/{id}:
    get:
      operationId: getBatch
      tags: [Bot Checks]
      summary: Get a batch's progress and results
      description: |
        Owner-scoped — a batch ID belonging to another account returns `404`.

        Poll until `status` is `completed`. Members reach their terminal state
        independently, so a mid-run poll returns a mix of `completed`,
        `failed`, and `processing` members — the terminal ones are final and
        safe to consume before the rest finish. A member left `processing`
        long after the others have settled is stuck on our side; it won't be
        refunded automatically, so contact support with the `batch_id`.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, format: uuid }
          description: The `batch_id` returned by `POST /api/v1/bot-checks/batch`.
      responses:
        '200':
          description: The batch, with every member's current status.
          headers:
            X-Request-Id: { $ref: '#/components/headers/XRequestId' }
            X-RateLimit-Limit: { $ref: '#/components/headers/XRateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/XRateLimitRemaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/XRateLimitReset' }
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BatchStatus'
              example:
                batch_id: b6e4f0aa-9c2a-4f36-8e7b-1a2b3c4d5e6f
                status: completed
                progress: { completed: 10, failed: 1, total: 11 }
                cost_per_check_cents: 32
                cost_cents: 352
                refunded_cents: 32
                results:
                  - check_id: 11111111-1111-4111-8111-111111111111
                    username: alice
                    status: completed
                    bot_probability: 0.12
                    verdict: Looks human
                    reasoning:
                      - Long-standing account with consistent posting history
                    cached: true
                    checked_at: "2026-08-09T18:00:03Z"
                    cost_cents: 32
                  - check_id: 22222222-2222-4222-8222-222222222222
                    username: mallory
                    status: failed
                    error:
                      code: account_not_found
                      message: account not found on X
                    refunded: true
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
  /api/v1/balance:
    get:
      operationId: getBalance
      tags: [Account]
      summary: Get your current balance
      description: Top up at https://bot-hound.com/home — there is no top-up endpoint on this API.
      responses:
        '200':
          description: Current prepaid balance.
          headers:
            X-Request-Id: { $ref: '#/components/headers/XRequestId' }
            X-RateLimit-Limit: { $ref: '#/components/headers/XRateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/XRateLimitRemaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/XRateLimitReset' }
          content:
            application/json:
              schema:
                type: object
                required: [balance_cents]
                properties:
                  balance_cents: { type: integer }
              example:
                balance_cents: 4296
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
  /api/v1/usage:
    get:
      operationId: getUsage
      tags: [Account]
      summary: Get your usage for the last 30 days
      description: >-
        Daily counters for the trailing 30 UTC days (today and the 29 before
        it). Days with no activity are omitted, not zero-filled. See it
        rendered at https://bot-hound.com/api too.
      responses:
        '200':
          description: Daily usage, most recent first, plus totals.
          headers:
            X-Request-Id: { $ref: '#/components/headers/XRequestId' }
            X-RateLimit-Limit: { $ref: '#/components/headers/XRateLimitLimit' }
            X-RateLimit-Remaining: { $ref: '#/components/headers/XRateLimitRemaining' }
            X-RateLimit-Reset: { $ref: '#/components/headers/XRateLimitReset' }
          content:
            application/json:
              schema:
                type: object
                required: [days, totals]
                properties:
                  days:
                    type: array
                    items: { $ref: '#/components/schemas/UsageDay' }
                  totals:
                    $ref: '#/components/schemas/UsageTotals'
              example:
                days:
                  - { date: "2026-08-09", requests: 41, checks: 12, spent_cents: 352, refunded_cents: 32 }
                  - { date: "2026-08-08", requests: 5, checks: 1, spent_cents: 40, refunded_cents: 0 }
                totals: { requests: 46, checks: 13, spent_cents: 392, refunded_cents: 32 }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { $ref: '#/components/responses/RateLimited' }
        '500': { $ref: '#/components/responses/InternalError' }
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      description: >-
        Your Bot Hound API key: `bh_live_…` in production, `bh_stg_…` on
        staging. One key per account. Get it, and regenerate it, at
        https://bot-hound.com/api.
  parameters:
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: false
      description: >-
        A caller-generated token, up to 128 characters. Resending the same
        body with the same key replays the original response once the first
        attempt has completed successfully — no recharge. If that first
        attempt instead failed (insufficient balance, an internal error,
        ...), the key is released and a retry with the same body re-executes
        as a fresh request. Reusing the key with a different body returns
        `409`, as does a retry that arrives while the original request is
        still in flight.
      schema:
        type: string
        maxLength: 128
      example: order-4471-retry-1
  headers:
    XRequestId:
      description: Request ID for this response — include it when contacting support.
      schema: { type: string }
    XRateLimitLimit:
      description: >-
        Your per-key rate limit, in requests per minute. Present on every
        response once the request authenticated — absent on a 401 (the key
        never resolved to an account) and on the rare 500 that happens before
        auth completes.
      schema: { type: integer }
    XRateLimitRemaining:
      description: Requests remaining in the current window. Same presence rule as X-RateLimit-Limit.
      schema: { type: integer }
    XRateLimitReset:
      description: Unix timestamp (seconds) when the current window resets. Same presence rule as X-RateLimit-Limit.
      schema: { type: integer }
  schemas:
    Error:
      type: object
      required: [error]
      properties:
        error:
          type: object
          required: [code, message, request_id]
          properties:
            code: { type: string, description: Machine-readable error code. }
            message: { type: string, description: Human-readable explanation. }
            request_id: { type: string, description: Same value as the X-Request-Id response header. }
    CheckResult:
      type: object
      description: >-
        A single check's state. `bot_probability`/`verdict`/`reasoning`/
        `cached`/`checked_at`/`cost_cents` are only present once `status` is
        `completed`; `error`/`refunded` are only present once `status` is
        `failed`.
      required: [check_id, username, status]
      properties:
        check_id: { type: string, format: uuid }
        username: { type: string }
        status:
          type: string
          enum: [processing, completed, failed]
        bot_probability:
          type: number
          minimum: 0
          maximum: 1
          description: Present when status is completed.
        verdict:
          type: string
          enum: [Looks human, Some bot signals, Likely a bot, Very likely a bot]
          description: Present when status is completed.
        reasoning:
          type: array
          items: { type: string }
          description: Present when status is completed.
        cached:
          type: boolean
          description: True if this result came from the 7-day verdict cache. Present when status is completed. Cache hits are billed at full price.
        checked_at:
          type: string
          format: date-time
          description: Present when status is completed.
        cost_cents:
          type: integer
          description: What this check was billed, in cents. Present when status is completed.
        error:
          type: object
          description: Present when status is failed.
          required: [code, message]
          properties:
            code:
              type: string
              enum: [account_not_found, account_protected, fetch_failed, classification_failed]
            message: { type: string }
        refunded:
          type: boolean
          description: >-
            True if this check's charge was credited back to your balance.
            Present when status is failed. Every failed check is refunded, so
            `false` here means the credit didn't land — contact support with
            the `check_id`.
    BatchStatus:
      type: object
      description: >-
        A batch's current state. The batch was charged once, up front, for
        every accepted account; failed members are refunded individually as
        they happen, so `refunded_cents` climbs while the batch runs. Your net
        spend is `cost_cents - refunded_cents`.
      required: [batch_id, status, progress, cost_per_check_cents, cost_cents, refunded_cents, results]
      properties:
        batch_id: { type: string, format: uuid }
        status:
          type: string
          enum: [processing, completed]
          description: >-
            `processing` until every member reaches a terminal state, i.e.
            until `progress.completed + progress.failed == progress.total`.
            `completed` means the batch is *finished*, not that every member
            succeeded — a batch whose members all failed still reports
            `completed`. There is no `failed` batch status; read
            `progress.failed` and `refunded_cents` for the outcome.
        progress:
          type: object
          description: >-
            Member outcome counters. Members still running are
            `total - (completed + failed)`.
          required: [completed, failed, total]
          properties:
            completed: { type: integer, description: Members that produced a verdict. These stay billed. }
            failed: { type: integer, description: Members that terminally failed. Each one is refunded individually. }
            total: { type: integer, description: Accepted usernames after dedup — what you were charged for. }
        cost_per_check_cents: { type: integer, description: Per-account price applied to this batch. Also the per-member refund amount. }
        cost_cents:
          type: integer
          description: >-
            Total charged up front (`total × cost_per_check_cents`). This does
            not shrink as members fail — refunds are reported separately in
            `refunded_cents`.
        refunded_cents:
          type: integer
          description: >-
            Running total credited back for failed members. Normally
            `progress.failed × cost_per_check_cents` — a smaller value once
            the batch is `completed` means a refund didn't land, so contact
            support with the `batch_id`.
        results:
          type: array
          description: >-
            Member checks, in the order their usernames were accepted. May be
            shorter than `progress.total` on any given poll — a member that
            can't be read at that moment is omitted rather than failing the
            whole response, and appears on a later poll. Match members by
            `check_id` or `username`, not by position.
          items: { $ref: '#/components/schemas/CheckResult' }
    UsageDay:
      type: object
      required: [date, requests, checks, spent_cents, refunded_cents]
      properties:
        date: { type: string, format: date }
        requests: { type: integer, description: "All authenticated /api/v1 requests that day, not just checks." }
        checks: { type: integer, description: Accounts checked that day (single checks + batch members). }
        spent_cents: { type: integer }
        refunded_cents: { type: integer }
    UsageTotals:
      type: object
      required: [requests, checks, spent_cents, refunded_cents]
      properties:
        requests: { type: integer }
        checks: { type: integer }
        spent_cents: { type: integer }
        refunded_cents: { type: integer }
  responses:
    Unauthorized:
      description: >-
        Missing or malformed `Authorization` header, an unrecognized key, or a
        key that was replaced by a regeneration. Every operation can return
        this. Because it fires before your key resolves to an account, it
        never carries the X-RateLimit-* headers — only X-Request-Id.
      headers:
        X-Request-Id: { $ref: '#/components/headers/XRequestId' }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example:
            error:
              code: unauthorized
              message: missing or malformed Authorization header
              request_id: 6f9a2b3c-1d4e-4f5a-8b6c-7d8e9f0a1b2c
    InsufficientBalance:
      description: Your balance can't cover this request. Nothing was charged.
      headers:
        X-Request-Id: { $ref: '#/components/headers/XRequestId' }
        X-RateLimit-Limit: { $ref: '#/components/headers/XRateLimitLimit' }
        X-RateLimit-Remaining: { $ref: '#/components/headers/XRateLimitRemaining' }
        X-RateLimit-Reset: { $ref: '#/components/headers/XRateLimitReset' }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example:
            error:
              code: insufficient_balance
              message: "balance is 40 cents, this check costs 80; top up at https://bot-hound.com/home"
              request_id: 6f9a2b3c-1d4e-4f5a-8b6c-7d8e9f0a1b2c
    NotFound:
      description: Nothing matches that ID, or it belongs to another account.
      headers:
        X-Request-Id: { $ref: '#/components/headers/XRequestId' }
        X-RateLimit-Limit: { $ref: '#/components/headers/XRateLimitLimit' }
        X-RateLimit-Remaining: { $ref: '#/components/headers/XRateLimitRemaining' }
        X-RateLimit-Reset: { $ref: '#/components/headers/XRateLimitReset' }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example:
            error:
              code: not_found
              message: check not found
              request_id: 6f9a2b3c-1d4e-4f5a-8b6c-7d8e9f0a1b2c
    TargetNotFound:
      description: >-
        The submitted username doesn't exist on X. This is about the check's
        *target*, not the check itself — contrast with NotFound, which means
        an ID (a check or batch) wasn't found. Nothing was charged.
      headers:
        X-Request-Id: { $ref: '#/components/headers/XRequestId' }
        X-RateLimit-Limit: { $ref: '#/components/headers/XRateLimitLimit' }
        X-RateLimit-Remaining: { $ref: '#/components/headers/XRateLimitRemaining' }
        X-RateLimit-Reset: { $ref: '#/components/headers/XRateLimitReset' }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example:
            error:
              code: not_found
              message: account not found on X
              request_id: 6f9a2b3c-1d4e-4f5a-8b6c-7d8e9f0a1b2c
    Conflict:
      description: >-
        This Idempotency-Key was already used with a different request body,
        or a request using it is still in flight. A key attached to a
        request that *failed* is released, not held for this — retry it
        instead of expecting a 409.
      headers:
        X-Request-Id: { $ref: '#/components/headers/XRequestId' }
        X-RateLimit-Limit: { $ref: '#/components/headers/XRateLimitLimit' }
        X-RateLimit-Remaining: { $ref: '#/components/headers/XRateLimitRemaining' }
        X-RateLimit-Reset: { $ref: '#/components/headers/XRateLimitReset' }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example:
            error:
              code: conflict
              message: Idempotency-Key already used with a different request body
              request_id: 6f9a2b3c-1d4e-4f5a-8b6c-7d8e9f0a1b2c
    ValidationError:
      description: >-
        The request body or a value in it is invalid. `code` is usually
        `validation_error`; a single-check submission whose target account is
        protected returns `account_protected` instead.
      headers:
        X-Request-Id: { $ref: '#/components/headers/XRequestId' }
        X-RateLimit-Limit: { $ref: '#/components/headers/XRateLimitLimit' }
        X-RateLimit-Remaining: { $ref: '#/components/headers/XRateLimitRemaining' }
        X-RateLimit-Reset: { $ref: '#/components/headers/XRateLimitReset' }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example:
            error:
              code: validation_error
              message: invalid username
              request_id: 6f9a2b3c-1d4e-4f5a-8b6c-7d8e9f0a1b2c
    RateLimited:
      description: Per-key rate limit exceeded (60 requests/minute).
      headers:
        X-Request-Id: { $ref: '#/components/headers/XRequestId' }
        X-RateLimit-Limit: { $ref: '#/components/headers/XRateLimitLimit' }
        X-RateLimit-Remaining: { $ref: '#/components/headers/XRateLimitRemaining' }
        X-RateLimit-Reset: { $ref: '#/components/headers/XRateLimitReset' }
        Retry-After:
          description: Seconds to wait before retrying.
          schema: { type: integer }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example:
            error:
              code: rate_limited
              message: rate limit exceeded
              request_id: 6f9a2b3c-1d4e-4f5a-8b6c-7d8e9f0a1b2c
    InternalError:
      description: >-
        Something broke on our end. Safe to retry. Usually carries the
        X-RateLimit-* headers below, but the rare 500 that happens while
        resolving your key (before auth completes) does not — same as
        Unauthorized.
      headers:
        X-Request-Id: { $ref: '#/components/headers/XRequestId' }
        X-RateLimit-Limit: { $ref: '#/components/headers/XRateLimitLimit' }
        X-RateLimit-Remaining: { $ref: '#/components/headers/XRateLimitRemaining' }
        X-RateLimit-Reset: { $ref: '#/components/headers/XRateLimitReset' }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example:
            error:
              code: internal_error
              message: internal error
              request_id: 6f9a2b3c-1d4e-4f5a-8b6c-7d8e9f0a1b2c
    SubmissionInternalError:
      description: >-
        Something broke on our end while accepting your submission. Any charge
        this request had already applied is credited straight back, so nothing
        stays billed and no work was started — retry the whole request. For a
        batch this is all-or-nothing: the entire batch charge is reversed
        (never a per-account portion of it), and no `batch_id` exists to poll.
        In the rare case the credit itself doesn't land, your balance won't
        reflect the reversal — contact support with the `X-Request-Id`.
      headers:
        X-Request-Id: { $ref: '#/components/headers/XRequestId' }
        X-RateLimit-Limit: { $ref: '#/components/headers/XRateLimitLimit' }
        X-RateLimit-Remaining: { $ref: '#/components/headers/XRateLimitRemaining' }
        X-RateLimit-Reset: { $ref: '#/components/headers/XRateLimitReset' }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example:
            error:
              code: internal_error
              message: internal error
              request_id: 6f9a2b3c-1d4e-4f5a-8b6c-7d8e9f0a1b2c
    LookupFailed:
      description: Looking up the target account on X failed. Safe to retry; nothing was charged.
      headers:
        X-Request-Id: { $ref: '#/components/headers/XRequestId' }
        X-RateLimit-Limit: { $ref: '#/components/headers/XRateLimitLimit' }
        X-RateLimit-Remaining: { $ref: '#/components/headers/XRateLimitRemaining' }
        X-RateLimit-Reset: { $ref: '#/components/headers/XRateLimitReset' }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example:
            error:
              code: lookup_failed
              message: target lookup failed
              request_id: 6f9a2b3c-1d4e-4f5a-8b6c-7d8e9f0a1b2c
    ServiceUnavailable:
      description: Bot checks aren't available right now. Nothing was charged.
      headers:
        X-Request-Id: { $ref: '#/components/headers/XRequestId' }
        X-RateLimit-Limit: { $ref: '#/components/headers/XRateLimitLimit' }
        X-RateLimit-Remaining: { $ref: '#/components/headers/XRateLimitRemaining' }
        X-RateLimit-Reset: { $ref: '#/components/headers/XRateLimitReset' }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Error' }
          example:
            error:
              code: service_unavailable
              message: bot check not available
              request_id: 6f9a2b3c-1d4e-4f5a-8b6c-7d8e9f0a1b2c
