openapi: 3.1.0
info:
  title: Fora API
  version: 0.5.0
  summary: i18n infrastructure — translate Postgres content (text + images) on demand.
  description: |
    The Fora API translates strings and images into a fixed set of locales and
    delivers the results either via polling (`GET /v1/content/{id}`) or via
    webhook callback. Authentication uses a single bearer token per account
    (`Authorization: Bearer fora_live_...`).

    This spec documents the customer-facing surface only. Internal
    dashboard-only endpoints (mounted under `/internal/v1/`) and Stripe
    webhook routes are intentionally omitted — they are not part of the
    customer contract.

    The canonical source is `openapi/openapi.yaml` in the
    [getfora/fora](https://github.com/getfora/fora) repository. The same
    document is served live at `GET /v1/openapi.json` (JSON) and
    `GET /v1/openapi.yaml` (YAML) for tooling.
  contact:
    name: Fora support
    email: support@getfora.ai
    url: https://getfora.ai
  license:
    name: Proprietary
    url: https://getfora.ai/terms
servers:
  - url: https://api.getfora.ai
    description: Production
security:
  - bearerAuth: []
tags:
  - name: Translate
    description: Submit translation jobs and fetch their results.
  - name: Accounts
    description: Account creation and demo provisioning.
  - name: Storage
    description: Configure customer-owned S3 storage for translated images.
  - name: Connector
    description: |
      Heartbeats from the customer-deployed Postgres connector, plus a
      read endpoint for the calling account's registered connectors.
  - name: Webhooks
    description: |
      Register, list, and revoke per-account webhook endpoints. Each
      webhook gets its own HMAC signing secret returned ONCE on create.
      Foundation for EPIC-005 — signing, retry curve, and the deliveries
      surface build on this resource.

      ### Outbound delivery headers

      Every outbound webhook POST from Fora to your registered URL carries
      these request headers in addition to `Content-Type: application/json`:

      | Header | When | Value |
      |---|---|---|
      | `Fora-Event-Id` | Always | UUIDv4. Stable across all retry attempts of a single delivery; new for each fresh job. Use as your dedupe key. |
      | `Fora-Signature` | Webhook is `signed: true` | `t=<unix-seconds>,v1=<hex>` where `<hex>` is the lowercase hex of `HMAC-SHA256(secret_bytes, <t>.<raw_body>)`. The `<raw_body>` is the exact bytes of the request body — verify against the raw bytes you receive, NOT a re-marshalled JSON. |

      The signing scheme mirrors Stripe's `t=…,v1=…` format. The `v1` prefix
      reserves room for future scheme versions without breaking existing
      verifiers. See `https://getfora.ai/docs/webhooks` for runnable Node
      and Python verification snippets pinned to the same fixed test vector
      as the server's unit tests.

      Legacy jobs that pass `webhook_url` on `POST /v1/translate` without
      having registered a corresponding webhook are delivered as
      `signed: false` (the server auto-creates a row on first delivery).
      To opt into signing, register the same URL via `POST /v1/webhooks`
      with the default `signed: true` and store the returned `secret`.

      ### Retry curve

      The first delivery attempt happens inline immediately after the
      job is marked complete (the success path is one round-trip — no
      added latency on the happy case). If the inline attempt fails or
      returns a non-2xx status, the delivery is persisted and a
      background worker re-fires it on the curve below:

      | Attempt | Wait before this attempt |
      |---|---|
      | 1 | inline (no wait) |
      | 2 | 1 minute |
      | 3 | 5 minutes |
      | 4 | 30 minutes |
      | 5 | 2 hours |
      | 6 | 6 hours |

      Total budget across the 6 attempts is approximately 8.6 hours
      (1m + 5m + 30m + 2h + 6h between successive attempts), designed
      to survive realistic transient outages (deploy, ALB blip,
      brief 5xx). After the 6th failed attempt the delivery is marked
      `permanently_failed` and is no longer retried; the read API for
      delivery history will surface this state in a future ticket.

      `Fora-Event-Id` is stable across all attempts of a single
      delivery — use it as your dedupe key. `Fora-Signature` is
      recomputed per attempt because the `t=<unix-seconds>` component
      shifts; verify against the raw bytes you receive on each call.
  - name: Jobs
    description: |
      Read access to the calling account's translation jobs plus a
      first-class retry primitive. Cursor-paginated list endpoint
      (`GET /v1/jobs`) shipped in TICKET-025; the per-job get
      (`GET /v1/jobs/{id}`) and retry (`POST /v1/jobs/{id}/retry`)
      endpoints shipped in TICKET-026.

      `GET /v1/jobs/{id}` returns the full job record with per-locale
      `translations` inlined under a single key (Stripe-style "expanded
      resource"). `POST /v1/jobs/{id}/retry` re-submits the original
      payload as a brand-new job; the response shape mirrors
      `POST /v1/translate` so client code can treat the two entrypoints
      interchangeably.

      Retries honor the same `Idempotency-Key` middleware as
      `POST /v1/translate` — send the same key within 24h to dedupe a
      duplicate retry click without producing a second job.
  - name: Usage
    description: |
      Read-only view of the calling account's quota usage for the current
      monthly billing period plus a 30-day daily series. Mirrors the data the
      Fora dashboard's usage panel renders, scoped to the calling account so
      customers can build their own ops console on the same numbers.
  - name: Keys
    description: |
      Manage the calling account's named API keys. Mirrors the dashboard
      "API keys" view: list, create, revoke. The raw `api_key` field is
      returned ONCE on `POST /v1/keys` and never again — `GET /v1/keys`
      surfaces only `key_prefix`. Use these endpoints to provision a CI
      key, a staging key, or to rotate compromised credentials
      programmatically.

      The legacy primary key on the account itself (returned by
      `POST /v1/accounts` and rotated via the dashboard) is **not**
      represented here — these endpoints manage only the named, revocable
      keys created by `POST /v1/keys` (or the dashboard equivalent).

      ### Self-revoke is forbidden

      `DELETE /v1/keys/{id}` returns `409 cannot_revoke_self` if the
      `{id}` matches the api_keys row id of the key used to authenticate
      the request. To revoke the calling key, authenticate with a
      different key (or rotate via the dashboard). This prevents a
      one-key account from accidentally locking itself out.
  - name: Spec
    description: Self-serve API contract (this document).
paths:
  /v1/translate:
    post:
      tags: [Translate]
      summary: Submit a translation job
      description: |
        Creates an asynchronous translation job. Either `source_text` or
        `source_image_urls` (or both) must be provided. The job is queued
        immediately and the response returns the `job_id` for polling via
        `GET /v1/content/{id}`. If `webhook_url` is provided, Fora will POST
        the completed job to that URL once translation finishes.

        Quota is reserved synchronously before the response is returned. If
        the account exceeds its monthly free-tier limit, the request fails
        with `402 quota_exceeded`.
      operationId: createTranslateJob
      security:
        - bearerAuth: []
      parameters:
        - name: Idempotency-Key
          in: header
          required: false
          description: |
            Optional Stripe-style idempotency token. When set, the same key
            replayed within 24 hours with the SAME request body returns the
            cached response with `Idempotent-Replayed: true` set in the
            response headers. The same key replayed with a DIFFERENT body
            returns `409 idempotency_key_in_use`. If a request with the same
            key is currently in flight (still executing the handler) the
            sibling request returns `409 idempotency_key_in_progress` —
            retry shortly. Scope is per-account. Length 1..255 ASCII
            printable characters; otherwise the request is rejected with
            `400 invalid_idempotency_key`.
          schema:
            type: string
            minLength: 1
            maxLength: 255
            pattern: '^[\x20-\x7E]+$'
          example: "550e8400-e29b-41d4-a716-446655440000"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/TranslateRequest'
            examples:
              text:
                summary: Translate a string into Spanish and French
                value:
                  source_text: "Welcome to our marketplace. Buy and sell with confidence."
                  target_locales: ["es", "fr"]
              text_with_glossary:
                summary: Text with protected terms and a webhook
                value:
                  source_text: "Acme Pro subscribers get unlimited storage."
                  target_locales: ["es", "fr", "de"]
                  protected_terms: ["Acme", "Acme Pro"]
                  webhook_url: "https://example.com/webhooks/fora"
                  custom_instructions: "Use a friendly, marketing tone."
              image:
                summary: Translate one image into French and write to customer S3
                value:
                  source_image_urls:
                    - "https://cdn.example.com/banners/welcome-en.jpg"
                  target_locales: ["fr"]
                  image_destination_url: "https://my-bucket.s3.us-east-1.amazonaws.com/banners/welcome-fr.jpg"
      responses:
        '202':
          description: Job accepted and queued for translation.
          headers:
            Idempotent-Replayed:
              description: |
                Present and set to `true` when this 202 was served from the
                Idempotency-Key cache rather than executed fresh. Absent on
                a fresh request. See TICKET-024.
              schema:
                type: string
                enum: ["true"]
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/JobAccepted'
              examples:
                accepted:
                  value:
                    job_id: "550e8400-e29b-41d4-a716-446655440000"
                    status: "pending"
        '400':
          description: Invalid request (missing fields, unsupported locale, malformed URL, malformed Idempotency-Key, etc.).
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              examples:
                missing_target:
                  value:
                    error: "target_locales is required"
                    error_details:
                      type: "invalid_request_error"
                      code: "missing_target_locales"
                      param: "target_locales"
                      request_id: "req_01HX9PT2W4Y6NJZAB3CDEF0001"
                unsupported_locale:
                  value:
                    error: "unsupported locale: jp"
                    error_details:
                      type: "invalid_request_error"
                      code: "unsupported_locale"
                      param: "target_locales"
                      request_id: "req_01HX9PT2W4Y6NJZAB3CDEF0002"
                invalid_idempotency_key:
                  value:
                    error: "Idempotency-Key must be 1..255 printable ASCII characters"
                    error_details:
                      type: "invalid_request_error"
                      code: "invalid_idempotency_key"
                      param: "Idempotency-Key"
                      request_id: "req_01HX9PT2W4Y6NJZAB3CDEF0004"
        '401':
          $ref: '#/components/responses/Unauthorized'
        '402':
          description: Account has exceeded its plan quota for the month.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              examples:
                quota:
                  value:
                    error: "monthly character quota exceeded"
                    error_details:
                      type: "quota_exceeded_error"
                      code: "quota_exceeded"
                      request_id: "req_01HX9PT2W4Y6NJZAB3CDEF0003"
        '409':
          description: |
            The supplied `Idempotency-Key` cannot currently produce a stable
            response. Two cases:

            - `idempotency_key_in_use`: the key was previously used with a
              different request body within the 24h replay window. Either
              use a new key or re-send the original body byte-for-byte.
            - `idempotency_key_in_progress`: a request with the same key is
              currently being processed by the server. Retry shortly; the
              completed response (or its replay) will be available once the
              in-flight request finishes.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              examples:
                idempotency_key_in_use:
                  value:
                    error: "Idempotency-Key has already been used with a different request body"
                    error_details:
                      type: "invalid_request_error"
                      code: "idempotency_key_in_use"
                      param: "Idempotency-Key"
                      request_id: "req_01HX9PT2W4Y6NJZAB3CDEF0005"
                idempotency_key_in_progress:
                  value:
                    error: "a request with this Idempotency-Key is currently being processed; retry shortly"
                    error_details:
                      type: "invalid_request_error"
                      code: "idempotency_key_in_progress"
                      param: "Idempotency-Key"
                      request_id: "req_01HX9PT2W4Y6NJZAB3CDEF0006"
        '500':
          $ref: '#/components/responses/InternalError'
  /v1/content/{id}:
    get:
      tags: [Translate]
      summary: Fetch translated content for a job
      description: |
        Returns the translated text and/or image URLs for a given job and
        locale. While the job is still pending or running, the response is
        `202` with the current status (and optional `error` if the job has
        failed). Once the job is complete, the response is `200` with the
        translated content.

        The `locale` query parameter is required and must be one of the
        supported locales (`de`, `es`, `fr`, `it`, `pt-BR`).
      operationId: getJobContent
      security:
        - bearerAuth: []
      parameters:
        - name: id
          in: path
          required: true
          description: The job UUID returned from `POST /v1/translate`.
          schema:
            type: string
            format: uuid
            example: "550e8400-e29b-41d4-a716-446655440000"
        - name: locale
          in: query
          required: true
          description: Target locale to read.
          schema:
            $ref: '#/components/schemas/Locale'
      responses:
        '200':
          description: Job is complete; translated content is returned.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Content'
              examples:
                text_complete:
                  summary: Completed text translation
                  value:
                    job_id: "550e8400-e29b-41d4-a716-446655440000"
                    locale: "es"
                    status: "completed"
                    translated_text: "Bienvenido a nuestro mercado. Compra y vende con confianza."
                    translated_image_urls: []
                image_complete:
                  summary: Completed image translation
                  value:
                    job_id: "550e8400-e29b-41d4-a716-446655440000"
                    locale: "fr"
                    status: "completed"
                    translated_text: ""
                    translated_image_urls:
                      - "https://my-bucket.s3.us-east-1.amazonaws.com/banners/welcome-fr.jpg"
        '202':
          description: Job is still pending or has failed; check `status`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/JobInProgress'
              examples:
                pending:
                  value:
                    job_id: "550e8400-e29b-41d4-a716-446655440000"
                    status: "pending"
                    error: ""
                failed:
                  value:
                    job_id: "550e8400-e29b-41d4-a716-446655440000"
                    status: "failed"
                    error: "upstream model timeout after 3 retries"
        '400':
          description: Missing or unsupported locale, or malformed job id.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              examples:
                missing_locale:
                  value:
                    error: "locale query parameter is required"
                    error_details:
                      type: "invalid_request_error"
                      code: "missing_locale"
                      param: "locale"
                      request_id: "req_01HX9PT2W4Y6NJZAB3CDEF0010"
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          description: Job not found, or no translation exists for the requested locale.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              examples:
                job_not_found:
                  value:
                    error: "job not found"
                    error_details:
                      type: "not_found_error"
                      code: "job_not_found"
                      request_id: "req_01HX9PT2W4Y6NJZAB3CDEF0011"
  /v1/accounts:
    post:
      tags: [Accounts]
      summary: Create a new account
      description: |
        Creates an account from an email address and returns the raw API key.
        The key is shown exactly once — store it somewhere safe. If SES is
        configured server-side, a copy of the key is also emailed to the
        provided address.

        Rate-limited to 5 signups per hour per source IP.
      operationId: createAccount
      security: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateAccountRequest'
            examples:
              signup:
                value:
                  email: "engineer@example.com"
      responses:
        '201':
          description: Account created.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/CreateAccountResponse'
              examples:
                created:
                  value:
                    account_id: "5d4e2c4d-8b73-4f71-9e2c-b1ad8f0a9b72"
                    api_key: "fora_live_p4ZxQy8k3J9vR2nLmWd1cFxV6tHs0aB7"
                    message: "Your API key has been sent to engineer@example.com. Store it somewhere safe."
        '400':
          description: Missing or malformed email.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              examples:
                missing_email:
                  value:
                    error: "email is required"
                    error_details:
                      type: "invalid_request_error"
                      code: "missing_email"
                      param: "email"
                      request_id: "req_01HX9PT2W4Y6NJZAB3CDEF0020"
        '409':
          description: An account already exists for this email.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              examples:
                duplicate:
                  value:
                    error: "email already registered"
                    error_details:
                      type: "invalid_request_error"
                      code: "email_already_exists"
                      param: "email"
                      request_id: "req_01HX9PT2W4Y6NJZAB3CDEF0021"
        '429':
          description: Too many signup attempts from this IP.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              examples:
                rate_limited:
                  value:
                    error: "too many signups from this address, try again later"
                    error_details:
                      type: "rate_limit_error"
                      code: "rate_limit_signup"
                      request_id: "req_01HX9PT2W4Y6NJZAB3CDEF0022"
        '500':
          $ref: '#/components/responses/InternalError'
  /v1/quickstart:
    post:
      tags: [Accounts]
      summary: Create a throwaway demo account
      description: |
        Provisions an instant demo account with a synthetic email address and
        returns the raw API key plus three pre-built `curl` snippets so a
        developer can make a real API call within seconds.

        Intended for evaluation only. Demo accounts share the same free-tier
        limits as full accounts and are subject to periodic cleanup.
        Rate-limited to 3 demo accounts per hour per source IP.
      operationId: quickstart
      security: []
      responses:
        '201':
          description: Demo account created.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/QuickstartResponse'
              examples:
                created:
                  value:
                    api_key: "fora_live_q9vR2nLmWd1cFxV6tHs0aB7p4ZxQy8k3"
                    examples:
                      translate_text: "curl -X POST https://api.getfora.ai/v1/translate \\\n  -H 'Authorization: Bearer fora_live_q9vR2nLmWd1cFxV6tHs0aB7p4ZxQy8k3' \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"source_text\": \"Welcome to our marketplace. Buy and sell with confidence.\", \"target_locales\": [\"es\", \"fr\"]}'"
                      translate_image: "curl -X POST https://api.getfora.ai/v1/translate \\\n  -H 'Authorization: Bearer fora_live_q9vR2nLmWd1cFxV6tHs0aB7p4ZxQy8k3' \\\n  -H 'Content-Type: application/json' \\\n  -d '{\"source_image_urls\": [\"https://upload.wikimedia.org/wikipedia/commons/thumb/2/2f/Culinary_fruits_front_view.jpg/640px-Culinary_fruits_front_view.jpg\"], \"target_locales\": [\"fr\"]}'"
                      get_result: "curl 'https://api.getfora.ai/v1/content/{job_id}?locale=es' \\\n  -H 'Authorization: Bearer fora_live_q9vR2nLmWd1cFxV6tHs0aB7p4ZxQy8k3'"
        '429':
          description: Too many demo accounts created from this IP.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              examples:
                rate_limited:
                  value:
                    error: "too many demo accounts created from this IP; try again later"
                    error_details:
                      type: "rate_limit_error"
                      code: "rate_limit_quickstart"
                      request_id: "req_01HX9PT2W4Y6NJZAB3CDEF0030"
        '500':
          $ref: '#/components/responses/InternalError'
  /v1/accounts/storage:
    post:
      tags: [Storage]
      summary: Configure customer-owned S3 storage for image translations
      description: |
        Saves a customer-owned S3 bucket + AWS credentials so that translated
        images can be written directly into the customer's bucket via the
        `image_destination_url` field on `POST /v1/translate`. Credentials
        are KMS-encrypted at rest.

        The IAM user / role behind the supplied credentials needs only
        `s3:PutObject` on the destination bucket.
      operationId: setStorageConfig
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/StorageConfigRequest'
            examples:
              s3:
                value:
                  bucket_name: "my-bucket"
                  region: "us-east-1"
                  access_key_id: "AKIAIOSFODNN7EXAMPLE"
                  secret_access_key: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
      responses:
        '200':
          description: Storage configuration saved.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/StatusOK'
              examples:
                ok:
                  value:
                    status: "ok"
        '400':
          description: Missing or malformed storage fields.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              examples:
                missing_fields:
                  value:
                    error: "bucket_name, region, access_key_id, and secret_access_key are required"
                    error_details:
                      type: "invalid_request_error"
                      code: "missing_storage_fields"
                      request_id: "req_01HX9PT2W4Y6NJZAB3CDEF0040"
        '401':
          $ref: '#/components/responses/Unauthorized'
        '500':
          $ref: '#/components/responses/InternalError'
  /v1/accounts/me/connector/heartbeat:
    post:
      tags: [Connector]
      summary: Submit a connector heartbeat
      description: |
        Called by the customer-deployed Postgres connector once per polling
        cycle (default every 30 seconds). The heartbeat reports the
        connector's name plus simple sync metrics; the server upserts the
        latest record per `(account_id, connector_name)`. Used to power the
        dashboard's connector status pane and to detect stale connectors.

        Customers do not normally call this endpoint directly — it is part of
        the connector binary's contract. See
        [docs/connector](https://getfora.ai/docs/connector).
      operationId: connectorHeartbeat
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/HeartbeatRequest'
            examples:
              normal:
                value:
                  connector_name: "products-prod"
                  rows_synced: 1284
                  rows_failed: 0
                  backlog: 0
              with_failures:
                value:
                  connector_name: "blog-staging"
                  rows_synced: 42
                  rows_failed: 3
                  backlog: 17
      responses:
        '204':
          description: Heartbeat recorded.
        '400':
          description: Missing connector_name or malformed body.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              examples:
                missing_name:
                  value:
                    error: "connector_name is required"
                    error_details:
                      type: "invalid_request_error"
                      code: "missing_connector_name"
                      param: "connector_name"
                      request_id: "req_01HX9PT2W4Y6NJZAB3CDEF0050"
        '401':
          $ref: '#/components/responses/Unauthorized'
        '500':
          $ref: '#/components/responses/InternalError'
  /v1/connectors:
    get:
      tags: [Connector]
      summary: List the calling account's connectors
      description: |
        Returns every connector that has reported a heartbeat for the
        calling account, with the last-known sync metrics per
        connector. Mirrors the `connectors` array embedded in the
        dashboard-only stats payload — the API equivalent of the
        dashboard's connector status pane.

        An account with zero connectors returns `{"data": []}` (200,
        not 404). Cross-account isolation is enforced in SQL.

        No pagination — accounts have a handful of connectors per
        environment, not pages of them. Cursor pagination is deferred
        until volume warrants it.
      operationId: listConnectors
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Connectors for the calling account, newest heartbeat first.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ConnectorList'
              examples:
                two:
                  value:
                    data:
                      - connector_name: "products-prod"
                        synced_at: "2026-05-12T19:00:00Z"
                        rows_synced: 1284
                        rows_failed: 0
                        backlog: 0
                      - connector_name: "blog-staging"
                        synced_at: "2026-05-12T18:42:00Z"
                        rows_synced: 42
                        rows_failed: 3
                        backlog: 17
                empty:
                  value:
                    data: []
        '401':
          $ref: '#/components/responses/Unauthorized'
        '500':
          $ref: '#/components/responses/InternalError'
  /v1/webhooks:
    post:
      tags: [Webhooks]
      summary: Register a webhook endpoint
      description: |
        Creates a new webhook for the calling account and returns a freshly
        generated HMAC signing secret. **The raw `secret` is returned
        EXACTLY ONCE** in this response and is never readable again — the
        server keeps it KMS-encrypted at rest so future outbound deliveries
        can sign with it (EPIC-005 / TICKET-017), but no GET endpoint
        exposes the plaintext.

        After creation, only the first 8 characters of the secret are
        retrievable as `secret_prefix`, suitable for identifying the
        webhook in a UI without leaking the secret itself.

        Webhook URL must be `http://` or `https://` with a valid host —
        the same validation rule applied to the `webhook_url` field on
        `POST /v1/translate`.
      operationId: createWebhook
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateWebhookRequest'
            examples:
              signed:
                summary: Default — signed deliveries
                value:
                  url: "https://example.com/webhooks/fora"
              unsigned:
                summary: Opt-out — receivers that can't verify HMAC yet
                value:
                  url: "https://legacy.example.com/hooks/fora"
                  signed: false
      responses:
        '201':
          description: Webhook created. The raw `secret` field appears here ONCE — store it immediately.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WebhookCreated'
              examples:
                created:
                  value:
                    id: "8f4c2c4d-8b73-4f71-9e2c-b1ad8f0a9b72"
                    url: "https://example.com/webhooks/fora"
                    signed: true
                    secret: "whsec_p4ZxQy8k3J9vR2nLmWd1cFxV6tHs0aB7"
                    secret_prefix: "p4ZxQy8k"
                    created_at: "2026-05-12T19:00:00Z"
        '400':
          description: Missing or malformed URL.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              examples:
                missing_url:
                  value:
                    error: "url is required"
                    error_details:
                      type: "invalid_request_error"
                      code: "missing_webhook_url"
                      param: "url"
                      request_id: "req_01HX9PT2W4Y6NJZAB3CDEF0080"
                invalid_url:
                  value:
                    error: "url must be an http or https URL with a valid host"
                    error_details:
                      type: "invalid_request_error"
                      code: "invalid_webhook_url"
                      param: "url"
                      request_id: "req_01HX9PT2W4Y6NJZAB3CDEF0081"
        '401':
          $ref: '#/components/responses/Unauthorized'
        '500':
          $ref: '#/components/responses/InternalError'
    get:
      tags: [Webhooks]
      summary: List webhooks for the calling account
      description: |
        Returns all non-revoked webhooks owned by the calling account.
        Only `secret_prefix` (first 8 chars) is included — the raw
        signing secret is NEVER returned by this endpoint.
      operationId: listWebhooks
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Webhooks for the calling account.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WebhookList'
              examples:
                two:
                  value:
                    data:
                      - id: "8f4c2c4d-8b73-4f71-9e2c-b1ad8f0a9b72"
                        url: "https://example.com/webhooks/fora"
                        signed: true
                        secret_prefix: "p4ZxQy8k"
                        created_at: "2026-05-12T19:00:00Z"
                      - id: "1a2b3c4d-5e6f-4a8b-9c0d-1e2f3a4b5c6d"
                        url: "https://staging.example.com/hooks"
                        signed: false
                        secret_prefix: "ZxKj92L1"
                        created_at: "2026-05-10T11:42:00Z"
                empty:
                  value:
                    data: []
        '401':
          $ref: '#/components/responses/Unauthorized'
        '500':
          $ref: '#/components/responses/InternalError'
  /v1/webhooks/{id}:
    delete:
      tags: [Webhooks]
      summary: Revoke a webhook
      description: |
        Marks the webhook as revoked (sets `revoked_at = NOW()`).
        Idempotent — re-deleting an already-revoked webhook still
        returns `204`. A webhook that does not belong to the calling
        account returns `404` (cross-account opacity).
      operationId: deleteWebhook
      security:
        - bearerAuth: []
      parameters:
        - name: id
          in: path
          required: true
          description: The webhook UUID returned from `POST /v1/webhooks`.
          schema:
            type: string
            format: uuid
            example: "8f4c2c4d-8b73-4f71-9e2c-b1ad8f0a9b72"
      responses:
        '204':
          description: Webhook revoked (or already revoked — both succeed).
        '400':
          description: Malformed webhook id.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              examples:
                invalid_id:
                  value:
                    error: "invalid webhook id"
                    error_details:
                      type: "invalid_request_error"
                      code: "invalid_webhook_id"
                      param: "id"
                      request_id: "req_01HX9PT2W4Y6NJZAB3CDEF0082"
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          description: Webhook not found, or not owned by the calling account.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              examples:
                not_found:
                  value:
                    error: "webhook not found"
                    error_details:
                      type: "not_found_error"
                      code: "webhook_not_found"
                      request_id: "req_01HX9PT2W4Y6NJZAB3CDEF0083"
        '500':
          $ref: '#/components/responses/InternalError'
  /v1/webhook-deliveries:
    get:
      tags: [Webhooks]
      summary: List webhook deliveries for the calling account
      description: |
        Returns the calling account's webhook delivery attempts in
        newest-first order with cursor pagination. The response envelope
        is `{data, has_more, next_cursor?}` — the `next_cursor` field is
        **omitted (not null)** when `has_more` is `false`, matching the
        Stripe-style wire posture used by `GET /v1/jobs`.

        Pass the value of `next_cursor` back as the `cursor` query
        parameter to fetch the next page. The cursor is opaque
        (URL-safe base64 of a small JSON object encoding the
        `(delivered_at, id)` tuple of the last row on the current page) —
        do not parse it on the client side; treat it as a token.

        Cross-account isolation is enforced server-side via a join
        through the `webhooks` table; you cannot read another account's
        deliveries by guessing a cursor or delivery id.

        Customer-callable redelivery is intentionally NOT exposed — see
        the Webhooks tag description for why a leaked API key must not
        be able to trigger arbitrary replays at a customer-controlled
        URL. Manual redelivery exists as a dashboard-only operating
        action.
      operationId: listWebhookDeliveries
      security:
        - bearerAuth: []
      parameters:
        - name: limit
          in: query
          required: false
          description: |
            Page size. Default 25. Values above 100 are clamped to 100;
            values <= 0 or non-integers return `400 invalid_limit`.
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 25
          example: 25
        - name: cursor
          in: query
          required: false
          description: |
            Opaque pagination token returned in the previous page's
            `next_cursor`. Malformed cursors return `400 invalid_cursor`.
          schema:
            type: string
          example: "eyJjIjoiMjAyNi0wNS0xMlQxOTowMDowMFoiLCJpIjoiOGY0YzJjNGQtOGI3My00ZjcxLTllMmMtYjFhZDhmMGE5YjcyIn0"
        - name: status
          in: query
          required: false
          description: |
            Filter by terminal status. Invalid values return
            `400 invalid_status`.
          schema:
            type: string
            enum: [pending, succeeded, permanently_failed]
          example: permanently_failed
        - name: webhook_id
          in: query
          required: false
          description: |
            Restrict results to deliveries against a single registered
            webhook id. Malformed UUIDs return `400 invalid_webhook_id`.
          schema:
            type: string
            format: uuid
          example: "8f4c2c4d-8b73-4f71-9e2c-b1ad8f0a9b72"
        - name: since
          in: query
          required: false
          description: |
            Return only deliveries with `delivered_at >= since`. RFC3339
            timestamp; invalid values return `400 invalid_since`.
          schema:
            type: string
            format: date-time
          example: "2026-05-01T00:00:00Z"
      responses:
        '200':
          description: A page of deliveries. `next_cursor` is omitted when `has_more` is `false`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WebhookDeliveriesList'
              examples:
                first_page:
                  summary: First page, more rows available
                  value:
                    data:
                      - id: "f1e2d3c4-b5a6-4978-9685-abcdef012345"
                        webhook_id: "8f4c2c4d-8b73-4f71-9e2c-b1ad8f0a9b72"
                        event_id: "11111111-2222-4333-8444-555555555555"
                        job_id: "550e8400-e29b-41d4-a716-446655440000"
                        attempt: 6
                        final_status: "permanently_failed"
                        response_code: 500
                        last_error: "HTTP 500"
                        next_attempt_at: null
                        delivered_at: "2026-05-12T19:00:00Z"
                        created_at: "2026-05-12T19:00:00Z"
                        body_preview: "{\"job_id\":\"550e8400-e29b-41d4-a716-446655440000\",\"status\":\"completed\"}"
                        redelivered_from: null
                    has_more: true
                    next_cursor: "eyJjIjoiMjAyNi0wNS0xMlQxOTowMDowMFoiLCJpIjoiZjFlMmQzYzQtYjVhNi00OTc4LTk2ODUtYWJjZGVmMDEyMzQ1In0"
                last_page:
                  summary: Final page, next_cursor absent
                  value:
                    data: []
                    has_more: false
        '400':
          description: Malformed query parameter.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              examples:
                invalid_cursor:
                  value:
                    error: "cursor is not a valid pagination cursor"
                    error_details:
                      type: "invalid_request_error"
                      code: "invalid_cursor"
                      param: "cursor"
                      request_id: "req_01HX9PT2W4Y6NJZAB3CDEF0200"
                invalid_status:
                  value:
                    error: "status must be one of pending, succeeded, permanently_failed"
                    error_details:
                      type: "invalid_request_error"
                      code: "invalid_status"
                      param: "status"
                      request_id: "req_01HX9PT2W4Y6NJZAB3CDEF0201"
                invalid_webhook_id:
                  value:
                    error: "webhook_id must be a valid UUID"
                    error_details:
                      type: "invalid_request_error"
                      code: "invalid_webhook_id"
                      param: "webhook_id"
                      request_id: "req_01HX9PT2W4Y6NJZAB3CDEF0202"
        '401':
          $ref: '#/components/responses/Unauthorized'
        '500':
          $ref: '#/components/responses/InternalError'
  /internal/v1/webhook-deliveries/{id}/redeliver:
    post:
      tags: [Webhooks]
      summary: Manually redeliver a webhook (dashboard-only)
      description: |
        Mints a new `webhook_deliveries` row that copies the original
        delivery's payload and headers but assigns a fresh `event_id`
        and links back via `redelivered_from`. The retry worker
        (TICKET-018) picks the new row up on its next poll cycle (max
        ~30 second latency).

        **Dashboard-only.** Authenticates via the
        `X-Fora-Internal-Secret` header — customer API keys are NOT
        accepted on this path. See the Webhooks tag description for
        why customer-callable redelivery is intentionally not shipped.

        Cross-account opacity: a delivery owned by an account other than
        the one identified by `?email=` returns `404`, never `403`. The
        same code is returned when the email matches no account at all.
      operationId: redeliverWebhookDelivery
      security:
        - internalSecret: []
      parameters:
        - name: id
          in: path
          required: true
          description: The delivery UUID returned from `GET /v1/webhook-deliveries`.
          schema:
            type: string
            format: uuid
            example: "f1e2d3c4-b5a6-4978-9685-abcdef012345"
        - name: email
          in: query
          required: true
          description: |
            Account email. The operator must specify which account owns
            the delivery so the cross-account check resolves to a 404
            instead of silently succeeding against the wrong tenant.
          schema:
            type: string
            format: email
          example: "ops@example.com"
      responses:
        '200':
          description: Delivery enqueued for redelivery. Worker picks up within ~30s.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/WebhookDeliveryRedelivered'
              examples:
                ok:
                  value:
                    id: "9a8b7c6d-5e4f-4321-8765-fedcba987654"
                    event_id: "22222222-3333-4444-8555-666666666666"
                    redelivered_from: "f1e2d3c4-b5a6-4978-9685-abcdef012345"
                    status: "pending"
        '400':
          description: Missing or malformed `email` query param, or malformed delivery id.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              examples:
                missing_email:
                  value:
                    error: "email query parameter is required"
                    error_details:
                      type: "invalid_request_error"
                      code: "missing_account_email"
                      param: "email"
                      request_id: "req_01HX9PT2W4Y6NJZAB3CDEF0210"
                invalid_delivery_id:
                  value:
                    error: "invalid delivery id"
                    error_details:
                      type: "invalid_request_error"
                      code: "invalid_delivery_id"
                      param: "id"
                      request_id: "req_01HX9PT2W4Y6NJZAB3CDEF0211"
        '401':
          description: Missing or invalid internal secret.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              examples:
                unauthorized:
                  value:
                    error: "unauthorized"
                    error_details:
                      type: "authentication_error"
                      code: "unauthorized"
                      request_id: "req_01HX9PT2W4Y6NJZAB3CDEF0212"
        '404':
          description: |
            Delivery not found, or owned by an account other than the
            email's, or the email matches no account. Per the
            cross-account opacity rule, all three cases share the same
            response.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              examples:
                not_found:
                  value:
                    error: "delivery not found"
                    error_details:
                      type: "not_found_error"
                      code: "delivery_not_found"
                      request_id: "req_01HX9PT2W4Y6NJZAB3CDEF0213"
        '409':
          description: |
            Delivery is currently `pending` — the worker hasn't given up
            on it yet. Wait for it to reach a terminal status before
            issuing a manual redelivery.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              examples:
                in_progress:
                  value:
                    error: "delivery is still in retry; cannot redeliver until it reaches a terminal status"
                    error_details:
                      type: "invalid_request_error"
                      code: "delivery_in_progress"
                      request_id: "req_01HX9PT2W4Y6NJZAB3CDEF0214"
        '500':
          $ref: '#/components/responses/InternalError'
  /v1/jobs:
    get:
      tags: [Jobs]
      summary: List the calling account's translation jobs
      description: |
        Returns the calling account's jobs in newest-first order with
        cursor pagination. The response envelope is `{data, has_more,
        next_cursor?}` — the `next_cursor` field is **omitted (not
        null)** when `has_more` is `false`, matching the Stripe-style
        wire posture used elsewhere in the API.

        Pass the value of `next_cursor` back as the `cursor` query
        parameter to fetch the next page. The cursor is opaque
        (URL-safe base64 of a small JSON object encoding the
        `(created_at, id)` tuple of the last row on the current page) —
        do not parse it on the client side; treat it as a token.

        Cross-account isolation is enforced server-side; you cannot
        read another account's jobs by guessing a cursor or job id.
      operationId: listJobs
      security:
        - bearerAuth: []
      parameters:
        - name: limit
          in: query
          required: false
          description: |
            Page size. Default 25. Values above 100 are clamped to 100;
            values <= 0 or non-integers return `400 invalid_limit`.
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 25
          example: 25
        - name: cursor
          in: query
          required: false
          description: |
            Opaque pagination token returned in the previous page's
            `next_cursor`. Malformed cursors return `400 invalid_cursor`.
          schema:
            type: string
          example: "eyJjIjoiMjAyNi0wNS0xMlQxOTowMDowMFoiLCJpIjoiOGY0YzJjNGQtOGI3My00ZjcxLTllMmMtYjFhZDhmMGE5YjcyIn0"
        - name: status
          in: query
          required: false
          description: |
            Filter by lifecycle status. Invalid values return
            `400 invalid_status`.
          schema:
            $ref: '#/components/schemas/JobStatus'
          example: failed
        - name: since
          in: query
          required: false
          description: |
            Return only jobs with `created_at >= since`. RFC3339
            timestamp; invalid values return `400 invalid_since`.
          schema:
            type: string
            format: date-time
          example: "2026-05-01T00:00:00Z"
      responses:
        '200':
          description: A page of jobs. `next_cursor` is omitted when `has_more` is `false`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/JobsList'
              examples:
                first_page:
                  summary: First page, more rows available
                  value:
                    data:
                      - id: "8f4c2c4d-8b73-4f71-9e2c-b1ad8f0a9b72"
                        status: "completed"
                        target_locales: ["es", "fr"]
                        has_text: true
                        has_images: false
                        created_at: "2026-05-12T19:00:00Z"
                        completed_at: "2026-05-12T19:00:04Z"
                        error: null
                        source_text: "Welcome to our marketplace."
                        quality_score: 0.92
                    has_more: true
                    next_cursor: "eyJjIjoiMjAyNi0wNS0xMlQxOTowMDowMFoiLCJpIjoiOGY0YzJjNGQtOGI3My00ZjcxLTllMmMtYjFhZDhmMGE5YjcyIn0"
                last_page:
                  summary: Final page, next_cursor absent
                  value:
                    data:
                      - id: "1a2b3c4d-5e6f-4a8b-9c0d-1e2f3a4b5c6d"
                        status: "failed"
                        target_locales: ["de"]
                        has_text: true
                        has_images: false
                        created_at: "2026-05-10T08:30:00Z"
                        completed_at: "2026-05-10T08:30:02Z"
                        error: "translation provider timeout"
                        source_text: "Hello world"
                        quality_score: null
                    has_more: false
        '400':
          description: Malformed query parameter.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              examples:
                invalid_limit:
                  value:
                    error: "limit must be a positive integer"
                    error_details:
                      type: "invalid_request_error"
                      code: "invalid_limit"
                      param: "limit"
                      request_id: "req_01HX9PT2W4Y6NJZAB3CDEF0090"
                invalid_cursor:
                  value:
                    error: "cursor is not a valid pagination cursor"
                    error_details:
                      type: "invalid_request_error"
                      code: "invalid_cursor"
                      param: "cursor"
                      request_id: "req_01HX9PT2W4Y6NJZAB3CDEF0091"
                invalid_status:
                  value:
                    error: "status must be one of pending, processing, completed, failed"
                    error_details:
                      type: "invalid_request_error"
                      code: "invalid_status"
                      param: "status"
                      request_id: "req_01HX9PT2W4Y6NJZAB3CDEF0092"
                invalid_since:
                  value:
                    error: "since must be an RFC3339 timestamp (e.g. 2026-05-01T00:00:00Z)"
                    error_details:
                      type: "invalid_request_error"
                      code: "invalid_since"
                      param: "since"
                      request_id: "req_01HX9PT2W4Y6NJZAB3CDEF0093"
        '401':
          $ref: '#/components/responses/Unauthorized'
        '500':
          $ref: '#/components/responses/InternalError'
  /v1/jobs/{id}:
    get:
      tags: [Jobs]
      summary: Fetch a single translation job (with per-locale translations)
      description: |
        Returns the full job record for a single translation job owned by
        the calling account. Per-locale translation results are inlined
        under the `translations` key (Stripe-style "expanded resource") so
        a polling client can read everything about a job in one round
        trip — no need to issue one `GET /v1/content/{id}?locale=...`
        request per target locale.

        Cross-account isolation is enforced server-side: a job owned by
        another account returns `404 job_not_found` (NOT `403`) so the
        endpoint never leaks the existence of an id you do not own.

        Snake_case end-to-end. The wire shape is pinned by
        `TestJobDetail_StoreTypesSerializeAsSnakeCase` in
        `internal/api/jobs_get_test.go`.
      operationId: getJob
      security:
        - bearerAuth: []
      parameters:
        - name: id
          in: path
          required: true
          description: The job UUID returned from `POST /v1/translate`.
          schema:
            type: string
            format: uuid
            example: "8f4c2c4d-8b73-4f71-9e2c-b1ad8f0a9b72"
      responses:
        '200':
          description: Job record with per-locale results inlined.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/JobDetail'
              examples:
                completed_text:
                  summary: Completed text job, two locales
                  value:
                    id: "8f4c2c4d-8b73-4f71-9e2c-b1ad8f0a9b72"
                    status: "completed"
                    source_text: "Welcome to our marketplace."
                    source_image_urls: []
                    target_locales: ["es", "fr"]
                    webhook_url: "https://example.com/webhooks/fora"
                    image_destination_url: null
                    protected_terms: ["Acme"]
                    custom_instructions: "Use a friendly, marketing tone."
                    created_at: "2026-05-12T19:00:00Z"
                    completed_at: "2026-05-12T19:00:04Z"
                    error: null
                    translations:
                      - locale: "es"
                        translated_text: "Bienvenido a nuestro mercado."
                        translated_image_urls: []
                        quality_score: null
                      - locale: "fr"
                        translated_text: "Bienvenue sur notre marché."
                        translated_image_urls: []
                        quality_score: null
                in_flight:
                  summary: Job is still pending; translations slice is empty
                  value:
                    id: "1a2b3c4d-5e6f-4a8b-9c0d-1e2f3a4b5c6d"
                    status: "pending"
                    source_text: "Hello world"
                    source_image_urls: []
                    target_locales: ["de"]
                    webhook_url: null
                    image_destination_url: null
                    protected_terms: []
                    custom_instructions: null
                    created_at: "2026-05-12T19:00:00Z"
                    completed_at: null
                    error: null
                    translations: []
        '400':
          description: Malformed job id.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              examples:
                invalid_job_id:
                  value:
                    error: "invalid job id"
                    error_details:
                      type: "invalid_request_error"
                      code: "invalid_job_id"
                      param: "id"
                      request_id: "req_01HX9PT2W4Y6NJZAB3CDEF0100"
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          description: Job not found, or owned by another account.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              examples:
                not_found:
                  value:
                    error: "job not found"
                    error_details:
                      type: "not_found_error"
                      code: "job_not_found"
                      request_id: "req_01HX9PT2W4Y6NJZAB3CDEF0101"
        '500':
          $ref: '#/components/responses/InternalError'
  /v1/jobs/{id}/retry:
    post:
      tags: [Jobs]
      summary: Re-submit a previous job's payload as a brand-new job
      description: |
        Reads the original job's `source_text`, `source_image_urls`,
        `target_locales`, `webhook_url`, `image_destination_url`,
        `protected_terms`, and `custom_instructions`, and submits them as
        a brand-new job (new `job_id`, `status: pending`). The response
        shape mirrors `POST /v1/translate` so retry callers and fresh
        callers can share serialization code.

        A retry counts as a new job for usage and is quota-checked the
        same way as a first-time submission.

        Status rules:

          - The original job must be in a terminal state (`completed` or
            `failed`). Retrying a `pending` or `processing` job returns
            `409 job_in_progress`. Customers who need at-most-once
            submission semantics across a network blip should send the
            same `Idempotency-Key` header — the wrapping middleware will
            replay the cached response instead of producing a second
            job.
          - Cross-account or missing source jobs return `404
            job_not_found` (NOT `403`); the endpoint does not leak the
            existence of jobs you do not own.

        The retry endpoint honors the same `Idempotency-Key` middleware
        as `POST /v1/translate` (TICKET-024). Same key + same source
        job within 24h returns the cached `202` with
        `Idempotent-Replayed: true` set in the response headers.
      operationId: retryJob
      security:
        - bearerAuth: []
      parameters:
        - name: id
          in: path
          required: true
          description: The source job UUID to retry.
          schema:
            type: string
            format: uuid
            example: "8f4c2c4d-8b73-4f71-9e2c-b1ad8f0a9b72"
        - name: Idempotency-Key
          in: header
          required: false
          description: |
            Optional Stripe-style idempotency token. When set, the same
            key replayed within 24 hours against the same source job
            returns the cached response with `Idempotent-Replayed: true`
            set in the response headers. Same key with a different
            source-job url (different request body, since chi includes
            the path in the body hash via the request line) returns
            `409 idempotency_key_in_use`. Length 1..255 ASCII printable
            characters; otherwise `400 invalid_idempotency_key`. See
            `POST /v1/translate` for the full contract.
          schema:
            type: string
            minLength: 1
            maxLength: 255
            pattern: '^[\x20-\x7E]+$'
          example: "retry-550e8400-e29b-41d4-a716-446655440000"
      responses:
        '202':
          description: Retry job accepted and queued. The new `job_id` is distinct from the source.
          headers:
            Idempotent-Replayed:
              description: |
                Present and set to `true` when this 202 was served from
                the Idempotency-Key cache rather than executed fresh.
                Absent on a fresh request. See TICKET-024.
              schema:
                type: string
                enum: ["true"]
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/JobAccepted'
              examples:
                accepted:
                  value:
                    job_id: "1a2b3c4d-5e6f-4a8b-9c0d-1e2f3a4b5c6d"
                    status: "pending"
        '400':
          description: Malformed source-job id or malformed Idempotency-Key.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              examples:
                invalid_job_id:
                  value:
                    error: "invalid job id"
                    error_details:
                      type: "invalid_request_error"
                      code: "invalid_job_id"
                      param: "id"
                      request_id: "req_01HX9PT2W4Y6NJZAB3CDEF0110"
                invalid_idempotency_key:
                  value:
                    error: "Idempotency-Key must be 1..255 printable ASCII characters"
                    error_details:
                      type: "invalid_request_error"
                      code: "invalid_idempotency_key"
                      param: "Idempotency-Key"
                      request_id: "req_01HX9PT2W4Y6NJZAB3CDEF0111"
        '401':
          $ref: '#/components/responses/Unauthorized'
        '402':
          description: Account has exceeded its plan quota for the month.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              examples:
                quota:
                  value:
                    error: "monthly character quota exceeded"
                    error_details:
                      type: "quota_exceeded_error"
                      code: "quota_exceeded"
                      request_id: "req_01HX9PT2W4Y6NJZAB3CDEF0112"
        '404':
          description: Source job not found, or owned by another account.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              examples:
                not_found:
                  value:
                    error: "job not found"
                    error_details:
                      type: "not_found_error"
                      code: "job_not_found"
                      request_id: "req_01HX9PT2W4Y6NJZAB3CDEF0113"
        '409':
          description: |
            The retry cannot proceed. Two cases:

            - `job_in_progress`: the source job is still `pending` or
              `processing`. Wait for it to reach a terminal state, or
              send `Idempotency-Key` to dedupe duplicate retry clicks.
            - `idempotency_key_in_use` / `idempotency_key_in_progress`:
              same as the `POST /v1/translate` 409 cases; see that
              endpoint's documentation.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              examples:
                in_progress:
                  value:
                    error: "job is still in progress; retry is rejected to prevent a duplicate submission. Send an Idempotency-Key header to retry-with-at-most-once semantics, or wait for the current attempt to complete."
                    error_details:
                      type: "invalid_request_error"
                      code: "job_in_progress"
                      request_id: "req_01HX9PT2W4Y6NJZAB3CDEF0114"
                idempotency_key_in_use:
                  value:
                    error: "Idempotency-Key has already been used with a different request body"
                    error_details:
                      type: "invalid_request_error"
                      code: "idempotency_key_in_use"
                      param: "Idempotency-Key"
                      request_id: "req_01HX9PT2W4Y6NJZAB3CDEF0115"
        '500':
          $ref: '#/components/responses/InternalError'
  /v1/usage:
    get:
      tags: [Usage]
      summary: Read this account's current-period quota usage
      description: |
        Returns the calling account's character and image counts for the
        current calendar-month billing period (UTC) plus a continuous 30-day
        daily series suitable for sparkline rendering.

        `chars_limit` and `images_limit` are JSON `null` for plans the server
        does not enforce a quota against (today: every plan other than
        `free`). Clients should treat `null` as "no enforced cap." The free
        tier is the only plan that returns numeric ceilings — currently
        10,000,000 characters and 2,000 images per period (Validation Pack
        limits, no trial expiry).

        `daily` always contains exactly 30 entries (or fewer for accounts
        younger than 30 days), one per day, ordered ascending by date. Days
        with zero usage are present as zero rows so client chart code never
        needs to interpolate missing dates.
      operationId: getUsage
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Usage snapshot for the calling account.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UsageResponse'
              examples:
                free_tier:
                  summary: Free-tier account mid-month
                  value:
                    chars_used: 12345
                    chars_limit: 10000000
                    images_used: 7
                    images_limit: 2000
                    plan: "free"
                    period_start: "2026-05-01T00:00:00Z"
                    period_end: "2026-06-01T00:00:00Z"
                    daily:
                      - date: "2026-05-11"
                        chars_processed: 0
                        images_processed: 0
                      - date: "2026-05-12"
                        chars_processed: 12345
                        images_processed: 7
                paid_tier:
                  summary: Paid-tier account (no enforced cap)
                  value:
                    chars_used: 4200000
                    chars_limit: null
                    images_used: 850
                    images_limit: null
                    plan: "starter"
                    period_start: "2026-05-01T00:00:00Z"
                    period_end: "2026-06-01T00:00:00Z"
                    daily: []
        '401':
          $ref: '#/components/responses/Unauthorized'
        '500':
          $ref: '#/components/responses/InternalError'
  /v1/keys:
    get:
      tags: [Keys]
      summary: List the calling account's named API keys
      description: |
        Returns all non-revoked named keys owned by the calling account.
        Only `key_prefix` (first 8 chars after `fora_live_`) is included
        — the raw `api_key` is **never** returned by this endpoint.

        The response envelope is `{data: [...]}` (not a bare array) to
        leave room for cursor pagination later. Today's accounts have
        at most a handful of named keys; pagination will be added when
        the upper end of the distribution justifies it.
      operationId: listKeys
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Named keys for the calling account.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/KeyList'
              examples:
                two:
                  value:
                    data:
                      - id: "8f4c2c4d-8b73-4f71-9e2c-b1ad8f0a9b72"
                        name: "ci-prod"
                        key_prefix: "fora_live_a1b2c3d4..."
                        created_at: "2026-05-12T19:00:00Z"
                        last_used_at: "2026-05-12T20:11:42Z"
                      - id: "1a2b3c4d-5e6f-4a8b-9c0d-1e2f3a4b5c6d"
                        name: "staging"
                        key_prefix: "fora_live_9f8e7d6c..."
                        created_at: "2026-05-10T11:42:00Z"
                        last_used_at: null
                empty:
                  value:
                    data: []
        '401':
          $ref: '#/components/responses/Unauthorized'
        '500':
          $ref: '#/components/responses/InternalError'
    post:
      tags: [Keys]
      summary: Create a new named API key
      description: |
        Creates a new named key for the calling account and returns the
        raw `api_key` in the response. **The raw `api_key` is returned
        EXACTLY ONCE** in this response — store it immediately. No
        subsequent endpoint exposes the plaintext (`GET /v1/keys`
        returns only `key_prefix`).

        ### Idempotency-Key

        This endpoint honors the `Idempotency-Key` header. Replays of a
        successful create within the 24h idempotency window return the
        cached response unchanged — **including the raw `api_key`
        field**. This is a deliberate exception to the "raw key returned
        once" rule: the cached response IS the original response, so
        replays will see the same plaintext `api_key`. Customers who
        require strict single-emit semantics on the raw key should not
        send `Idempotency-Key` on this endpoint.
      operationId: createKey
      security:
        - bearerAuth: []
      parameters:
        - name: Idempotency-Key
          in: header
          required: false
          description: |
            Optional client-supplied idempotency token. Same key + same
            body within 24h returns the cached response unchanged
            (including the raw `api_key` plaintext — see description
            above).
          schema:
            type: string
            maxLength: 255
            example: "key-create-2026-05-12-001"
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateKeyRequest'
            examples:
              ci:
                value:
                  name: "ci-prod"
      responses:
        '201':
          description: Key created. The raw `api_key` field appears here ONCE — store it immediately.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/KeyCreated'
              examples:
                created:
                  value:
                    id: "8f4c2c4d-8b73-4f71-9e2c-b1ad8f0a9b72"
                    name: "ci-prod"
                    key_prefix: "fora_live_a1b2c3d4..."
                    api_key: "fora_live_a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f9"
                    created_at: "2026-05-12T19:00:00Z"
        '400':
          description: Missing or malformed `name`.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              examples:
                missing_name:
                  value:
                    error: "name is required"
                    error_details:
                      type: "invalid_request_error"
                      code: "missing_name"
                      param: "name"
                      request_id: "req_01HX9PT2W4Y6NJZAB3CDEF0120"
        '401':
          $ref: '#/components/responses/Unauthorized'
        '500':
          $ref: '#/components/responses/InternalError'
  /v1/keys/{id}:
    delete:
      tags: [Keys]
      summary: Revoke a named API key
      description: |
        Marks the named key as revoked (sets `revoked_at = NOW()`).
        Subsequent requests authenticating with the revoked key return
        `401 invalid_api_key`. A key id that does not belong to the
        calling account returns `404` (cross-account opacity).

        **A key cannot revoke itself.** If `{id}` matches the api_keys
        row id of the key used to authenticate this request, the
        endpoint returns `409 cannot_revoke_self`. Use a different key,
        or rotate via the dashboard.
      operationId: deleteKey
      security:
        - bearerAuth: []
      parameters:
        - name: id
          in: path
          required: true
          description: The api_keys row id returned from `POST /v1/keys`.
          schema:
            type: string
            format: uuid
            example: "8f4c2c4d-8b73-4f71-9e2c-b1ad8f0a9b72"
      responses:
        '204':
          description: Key revoked.
        '400':
          description: Malformed key id.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              examples:
                invalid_id:
                  value:
                    error: "invalid key id"
                    error_details:
                      type: "invalid_request_error"
                      code: "invalid_key_id"
                      param: "id"
                      request_id: "req_01HX9PT2W4Y6NJZAB3CDEF0121"
        '401':
          $ref: '#/components/responses/Unauthorized'
        '404':
          description: Key not found, or not owned by the calling account.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              examples:
                not_found:
                  value:
                    error: "key not found"
                    error_details:
                      type: "not_found_error"
                      code: "key_not_found"
                      request_id: "req_01HX9PT2W4Y6NJZAB3CDEF0122"
        '409':
          description: |
            The `{id}` matches the api_keys row id of the key used to
            authenticate this request. Use a different key.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
              examples:
                self:
                  value:
                    error: "cannot revoke the API key used to authenticate this request; use a different key"
                    error_details:
                      type: "invalid_request_error"
                      code: "cannot_revoke_self"
                      param: "id"
                      request_id: "req_01HX9PT2W4Y6NJZAB3CDEF0123"
        '500':
          $ref: '#/components/responses/InternalError'
  /v1/openapi.json:
    get:
      tags: [Spec]
      summary: This API contract, as JSON
      description: |
        Returns this OpenAPI 3.1 document serialized as JSON. Suitable for
        SDK generators, doc renderers, and contract test tooling. Cached for
        5 minutes.
      operationId: getOpenAPIJSON
      security: []
      responses:
        '200':
          description: OpenAPI document (JSON).
          content:
            application/json:
              schema:
                type: object
                description: The OpenAPI 3.1 document.
        '4XX':
          description: Method not allowed or unknown sub-path.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          $ref: '#/components/responses/InternalError'
  /v1/openapi.yaml:
    get:
      tags: [Spec]
      summary: This API contract, as YAML
      description: |
        Returns this OpenAPI 3.1 document in its canonical YAML form. Cached
        for 5 minutes.
      operationId: getOpenAPIYAML
      security: []
      responses:
        '200':
          description: OpenAPI document (YAML).
          content:
            application/yaml:
              schema:
                type: string
                description: The raw YAML source of this document.
                example: "openapi: 3.1.0\ninfo:\n  title: Fora API\n  ..."
        '4XX':
          description: Method not allowed or unknown sub-path.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '500':
          $ref: '#/components/responses/InternalError'
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: fora_live_...
      description: |
        Pass the raw API key as `Authorization: Bearer fora_live_...`. Keys
        are issued by `POST /v1/accounts` or `POST /v1/quickstart`. Rotate
        via the dashboard.
    internalSecret:
      type: apiKey
      in: header
      name: X-Fora-Internal-Secret
      description: |
        Dashboard-only shared secret. Endpoints under `/internal/v1/`
        authenticate via this header instead of the customer API key —
        the dashboard ships the secret in its environment so the user
        never sees it. Customer-facing code MUST NOT call these
        endpoints; per the lane policy, the customer API key is not a
        valid credential here and will be rejected with `401`.
  responses:
    Unauthorized:
      description: Missing, malformed, or invalid API key.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          examples:
            missing:
              value:
                error: "missing API key"
                error_details:
                  type: "authentication_error"
                  code: "missing_api_key"
                  request_id: "req_01HX9PT2W4Y6NJZAB3CDEF0060"
            invalid:
              value:
                error: "invalid API key"
                error_details:
                  type: "authentication_error"
                  code: "invalid_api_key"
                  request_id: "req_01HX9PT2W4Y6NJZAB3CDEF0061"
    InternalError:
      description: Unhandled server-side error. The `request_id` should be quoted in any support ticket.
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/ErrorResponse'
          examples:
            internal:
              value:
                error: "failed to create job"
                error_details:
                  type: "internal_error"
                  code: "failed_create_job"
                  request_id: "req_01HX9PT2W4Y6NJZAB3CDEF0070"
  schemas:
    Locale:
      type: string
      description: A supported target locale.
      enum:
        - de
        - es
        - fr
        - it
        - pt-BR
      example: es
    JobStatus:
      type: string
      description: Lifecycle state of a translation job.
      enum:
        - pending
        - processing
        - completed
        - failed
      example: pending
    ErrorType:
      type: string
      description: Broad error category. Stable wire contract.
      enum:
        - invalid_request_error
        - authentication_error
        - permission_error
        - not_found_error
        - rate_limit_error
        - quota_exceeded_error
        - internal_error
      example: invalid_request_error
    ErrorCode:
      type: string
      description: |
        Granular machine-readable error code. New codes are additive within
        an API version. The full catalogue lives in `internal/api/codes.go`
        in the [getfora/fora](https://github.com/getfora/fora) repo.
      example: missing_target_locales
    ErrorDetails:
      type: object
      description: Structured error payload. Stable wire contract from TICKET-011.
      required:
        - type
        - code
        - request_id
      properties:
        type:
          $ref: '#/components/schemas/ErrorType'
        code:
          $ref: '#/components/schemas/ErrorCode'
        param:
          type: string
          description: |
            The name of the request field that caused the error, when the
            error is field-scoped. Omitted otherwise.
          example: target_locales
        request_id:
          type: string
          description: |
            The server-assigned request id. Quote this when contacting
            support — every log line for the request carries the same id.
          example: req_01HX9PT2W4Y6NJZAB3CDEF0001
    ErrorResponse:
      type: object
      description: |
        Canonical error response body. The top-level `error` field is a
        human-readable string preserved for legacy SDKs; new code should
        branch on `error_details.type` and `error_details.code`.
      required:
        - error
        - error_details
      properties:
        error:
          type: string
          description: Human-readable error message. Safe to surface to end users.
          example: "target_locales is required"
        error_details:
          $ref: '#/components/schemas/ErrorDetails'
    TranslateRequest:
      type: object
      description: Body for `POST /v1/translate`.
      properties:
        source_text:
          type: string
          description: The text to translate. Required if `source_image_urls` is empty.
          example: "Welcome to our marketplace."
        source_image_urls:
          type: array
          description: |
            HTTP(S) URLs of source images. Required if `source_text` is empty.
            Each image is fetched server-side and translated independently.
          items:
            type: string
            format: uri
          example:
            - "https://cdn.example.com/banners/welcome-en.jpg"
        target_locales:
          type: array
          description: Locales to translate into. At least one is required.
          minItems: 1
          items:
            $ref: '#/components/schemas/Locale'
          example: ["es", "fr"]
        webhook_url:
          type: string
          format: uri
          description: |
            Optional `http(s)` URL that receives a POST with the completed
            job. Deliveries to a URL that has NOT been registered via
            `POST /v1/webhooks` are unsigned (the server auto-creates a
            `signed: false` webhook row on first delivery so subsequent
            jobs reuse the same record). To get HMAC-signed deliveries
            with the `Fora-Signature` header, register the URL via
            `POST /v1/webhooks` first and pass the same URL here. The
            `Fora-Event-Id` dedupe header is set on every delivery
            regardless of signing — see the `Webhooks` tag description
            for the full header contract.
          example: "https://example.com/webhooks/fora"
        image_destination_url:
          type: string
          format: uri
          description: |
            Optional `https` S3 object URL where the translated image will
            be written. Requires exactly one source image and one target
            locale. The bucket credentials must already be saved via
            `POST /v1/accounts/storage`.
          example: "https://my-bucket.s3.us-east-1.amazonaws.com/banners/welcome-fr.jpg"
        protected_terms:
          type: array
          description: |
            Glossary of terms that must NOT be translated. Each term is at
            most 100 characters; up to 50 terms per request.
          maxItems: 50
          items:
            type: string
            maxLength: 100
          example: ["Acme", "Acme Pro"]
        custom_instructions:
          type: string
          description: |
            Free-form translation guidance, up to 500 characters. Forwarded
            to the model as a system instruction.
          maxLength: 500
          example: "Use a friendly, marketing tone."
    JobAccepted:
      type: object
      description: Response from `POST /v1/translate` when the job is queued.
      required: [job_id, status]
      properties:
        job_id:
          type: string
          format: uuid
          description: The id used for polling and webhook correlation.
          example: "550e8400-e29b-41d4-a716-446655440000"
        status:
          $ref: '#/components/schemas/JobStatus'
    JobInProgress:
      type: object
      description: Response from `GET /v1/content/{id}` while a job is still pending or has failed.
      required: [job_id, status]
      properties:
        job_id:
          type: string
          format: uuid
          example: "550e8400-e29b-41d4-a716-446655440000"
        status:
          $ref: '#/components/schemas/JobStatus'
        error:
          type: string
          description: Failure reason if `status` is `failed`. Empty otherwise.
          example: ""
    Content:
      type: object
      description: Translated content for one locale of a completed job.
      required: [job_id, locale, status, translated_text, translated_image_urls]
      properties:
        job_id:
          type: string
          format: uuid
          example: "550e8400-e29b-41d4-a716-446655440000"
        locale:
          $ref: '#/components/schemas/Locale'
        status:
          $ref: '#/components/schemas/JobStatus'
        translated_text:
          type: string
          description: The translated string. Empty for image-only jobs.
          example: "Bienvenido a nuestro mercado."
        translated_image_urls:
          type: array
          description: |
            HTTPS URLs of the translated images. For jobs that used
            `image_destination_url`, this is the customer-owned URL.
            Otherwise it is a Fora-hosted URL.
          items:
            type: string
            format: uri
          example:
            - "https://my-bucket.s3.us-east-1.amazonaws.com/banners/welcome-fr.jpg"
    CreateAccountRequest:
      type: object
      description: Body for `POST /v1/accounts`.
      required: [email]
      properties:
        email:
          type: string
          format: email
          description: Email address for the new account. Lowercased server-side.
          example: "engineer@example.com"
    CreateAccountResponse:
      type: object
      description: Response from `POST /v1/accounts`. The `api_key` is shown exactly once.
      required: [account_id, api_key, message]
      properties:
        account_id:
          type: string
          format: uuid
          example: "5d4e2c4d-8b73-4f71-9e2c-b1ad8f0a9b72"
        api_key:
          type: string
          description: "Raw API key. Use as `Authorization: Bearer <api_key>`."
          example: "fora_live_p4ZxQy8k3J9vR2nLmWd1cFxV6tHs0aB7"
        message:
          type: string
          example: "Your API key has been sent to engineer@example.com. Store it somewhere safe."
    QuickstartResponse:
      type: object
      description: Response from `POST /v1/quickstart`.
      required: [api_key, examples]
      properties:
        api_key:
          type: string
          description: Raw API key for the demo account.
          example: "fora_live_q9vR2nLmWd1cFxV6tHs0aB7p4ZxQy8k3"
        examples:
          type: object
          description: Pre-rendered `curl` snippets bound to this account's key and the current host.
          required: [translate_text, translate_image, get_result]
          properties:
            translate_text:
              type: string
              description: Curl snippet for `POST /v1/translate` with text.
            translate_image:
              type: string
              description: Curl snippet for `POST /v1/translate` with an image.
            get_result:
              type: string
              description: Curl snippet for `GET /v1/content/{job_id}?locale=es`.
    StorageConfigRequest:
      type: object
      description: Body for `POST /v1/accounts/storage`.
      required: [bucket_name, region, access_key_id, secret_access_key]
      properties:
        bucket_name:
          type: string
          description: Destination S3 bucket name.
          example: "my-bucket"
        region:
          type: string
          description: AWS region of the bucket.
          example: "us-east-1"
        access_key_id:
          type: string
          description: AWS access key id with `s3:PutObject` on the destination bucket.
          example: "AKIAIOSFODNN7EXAMPLE"
        secret_access_key:
          type: string
          description: Corresponding AWS secret access key. KMS-encrypted at rest.
          example: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
    StatusOK:
      type: object
      required: [status]
      properties:
        status:
          type: string
          example: "ok"
    CreateWebhookRequest:
      type: object
      description: Body for `POST /v1/webhooks`.
      required: [url]
      properties:
        url:
          type: string
          format: uri
          description: |
            HTTP or HTTPS URL the webhook will POST to. Must include a
            valid host. Same validation as `webhook_url` on
            `POST /v1/translate`.
          example: "https://example.com/webhooks/fora"
        signed:
          type: boolean
          description: |
            Whether outbound deliveries to this webhook should carry the
            HMAC-SHA256 signature header (computed from the per-webhook
            secret returned at creation). Defaults to `true`. Set to
            `false` only for legacy receivers that cannot verify the
            signature. EPIC-005 / TICKET-017 ships the actual signing.
          default: true
          example: true
    WebhookCreated:
      type: object
      description: |
        Response from `POST /v1/webhooks`. The `secret` field is the raw
        signing secret and is returned EXACTLY ONCE — store it
        immediately.
      required: [id, url, signed, secret, secret_prefix, created_at]
      properties:
        id:
          type: string
          format: uuid
          description: The webhook id, used in `DELETE /v1/webhooks/{id}`.
          example: "8f4c2c4d-8b73-4f71-9e2c-b1ad8f0a9b72"
        url:
          type: string
          format: uri
          example: "https://example.com/webhooks/fora"
        signed:
          type: boolean
          description: Whether outbound deliveries carry the HMAC signature header.
          example: true
        secret:
          type: string
          description: |
            Raw signing secret, prefixed `whsec_`. **Returned exactly
            once.** No subsequent endpoint exposes the plaintext —
            store it in your secrets manager immediately.
          example: "whsec_p4ZxQy8k3J9vR2nLmWd1cFxV6tHs0aB7"
        secret_prefix:
          type: string
          description: |
            First 8 characters of the secret body (after the `whsec_`
            literal). Safe to display — used to identify the webhook
            in a UI without leaking the secret.
          example: "p4ZxQy8k"
        created_at:
          type: string
          format: date-time
          example: "2026-05-12T19:00:00Z"
    Webhook:
      type: object
      description: |
        Webhook record as returned by `GET /v1/webhooks`. The raw
        `secret` field is intentionally absent — only `secret_prefix`
        is included.
      required: [id, url, signed, secret_prefix, created_at]
      properties:
        id:
          type: string
          format: uuid
          example: "8f4c2c4d-8b73-4f71-9e2c-b1ad8f0a9b72"
        url:
          type: string
          format: uri
          example: "https://example.com/webhooks/fora"
        signed:
          type: boolean
          example: true
        secret_prefix:
          type: string
          description: First 8 characters of the secret body. Safe to display.
          example: "p4ZxQy8k"
        created_at:
          type: string
          format: date-time
          example: "2026-05-12T19:00:00Z"
    WebhookList:
      type: object
      description: Response from `GET /v1/webhooks`.
      required: [data]
      properties:
        data:
          type: array
          description: Non-revoked webhooks for the calling account, newest first.
          items:
            $ref: '#/components/schemas/Webhook'
    WebhookDeliveryStatus:
      type: string
      description: |
        Terminal status of a single webhook delivery row. `pending` rows
        are still moving through the retry curve (TICKET-018);
        `succeeded` and `permanently_failed` are terminal.
      enum:
        - pending
        - succeeded
        - permanently_failed
      example: succeeded
    WebhookDelivery:
      type: object
      description: |
        One persisted webhook delivery attempt. Returned by
        `GET /v1/webhook-deliveries`. The full request payload is
        truncated to `body_preview` (first 1000 bytes); a future
        single-delivery endpoint will surface the full body when needed.
        The HMAC signing secret is **never** part of this shape — the
        signature lives in the outbound HTTP header at delivery time and
        is not stored.
      required:
        - id
        - webhook_id
        - event_id
        - job_id
        - attempt
        - final_status
        - response_code
        - last_error
        - next_attempt_at
        - delivered_at
        - created_at
        - body_preview
        - redelivered_from
      properties:
        id:
          type: string
          format: uuid
          example: "f1e2d3c4-b5a6-4978-9685-abcdef012345"
        webhook_id:
          type: [string, "null"]
          format: uuid
          description: |
            The webhook this delivery targeted. Null for legacy rows
            that pre-date the EPIC-005 webhooks resource and were never
            associated with a registered webhook.
          example: "8f4c2c4d-8b73-4f71-9e2c-b1ad8f0a9b72"
        event_id:
          type: string
          format: uuid
          description: |
            Stable across all retry attempts of a single delivery; new
            for each fresh job. This is the value the receiver sees in
            the `Fora-Event-Id` header — use it as your dedupe key.
          example: "11111111-2222-4333-8444-555555555555"
        job_id:
          type: string
          format: uuid
          description: The job whose completion triggered this delivery.
          example: "550e8400-e29b-41d4-a716-446655440000"
        attempt:
          type: integer
          minimum: 0
          description: |
            Number of HTTP attempts made so far. 1 after the inline
            first attempt; bumped on each retry. A redelivery starts at
            0 and is incremented to 1 on its first try.
          example: 6
        final_status:
          $ref: '#/components/schemas/WebhookDeliveryStatus'
        response_code:
          type: [integer, "null"]
          description: HTTP response code from the most recent attempt. Null if no attempt has yet returned a response.
          example: 500
        last_error:
          type: [string, "null"]
          description: Human-readable error from the most recent failed attempt. Null on succeeded rows.
          example: "HTTP 500"
        next_attempt_at:
          type: [string, "null"]
          format: date-time
          description: When the worker will fire the next attempt. Null on terminal rows.
          example: null
        delivered_at:
          type: string
          format: date-time
          description: |
            Timestamp of the most recent attempt. Renamed from the
            legacy column name; monotonically advanced by each retry.
          example: "2026-05-12T19:00:00Z"
        created_at:
          type: string
          format: date-time
          description: |
            Same value as `delivered_at` for now (the table only tracks
            one timestamp). Populated for forward-compat with a future
            schema split.
          example: "2026-05-12T19:00:00Z"
        body_preview:
          type: string
          description: |
            First 1000 bytes of the rendered request payload. If the
            full payload exceeded 1000 bytes, the suffix `…(truncated)`
            is appended. The signature secret is NEVER in this string —
            the payload is the JSON job result body, computed once at
            enqueue time, and the signature is in the outbound HTTP
            header at delivery time.
          example: "{\"job_id\":\"550e8400-e29b-41d4-a716-446655440000\",\"status\":\"completed\"}"
        redelivered_from:
          type: [string, "null"]
          format: uuid
          description: |
            For rows minted by the dashboard-only redeliver action
            (`POST /internal/v1/webhook-deliveries/{id}/redeliver`),
            the id of the original delivery this row replaces. Null on
            non-redelivery rows.
          example: null
    WebhookDeliveriesList:
      type: object
      description: |
        Cursor-paginated response from `GET /v1/webhook-deliveries`.
        The `next_cursor` field is **omitted (not null)** when
        `has_more` is `false` — callers should branch on key presence,
        not on a null value. Stripe-style.
      required: [data, has_more]
      properties:
        data:
          type: array
          description: Deliveries on this page, newest first.
          items:
            $ref: '#/components/schemas/WebhookDelivery'
        has_more:
          type: boolean
          description: True if at least one row exists beyond this page.
          example: true
        next_cursor:
          type: string
          description: |
            Opaque pagination token. Pass back as `?cursor=...` to
            fetch the next page. **Omitted** when `has_more` is
            `false`.
          example: "eyJjIjoiMjAyNi0wNS0xMlQxOTowMDowMFoiLCJpIjoiZjFlMmQzYzQtYjVhNi00OTc4LTk2ODUtYWJjZGVmMDEyMzQ1In0"
    WebhookDeliveryRedelivered:
      type: object
      description: |
        Response from
        `POST /internal/v1/webhook-deliveries/{id}/redeliver`. The new
        delivery row is enqueued with `final_status: pending`; the
        retry worker picks it up on its next poll cycle (max ~30s
        latency).
      required: [id, event_id, redelivered_from, status]
      properties:
        id:
          type: string
          format: uuid
          description: Id of the new delivery row.
          example: "9a8b7c6d-5e4f-4321-8765-fedcba987654"
        event_id:
          type: string
          format: uuid
          description: |
            Fresh `Fora-Event-Id` for the redelivery. Distinct from the
            original — the receiver's dedupe MUST treat this as a new
            event.
          example: "22222222-3333-4444-8555-666666666666"
        redelivered_from:
          type: string
          format: uuid
          description: Id of the original delivery being replayed.
          example: "f1e2d3c4-b5a6-4978-9685-abcdef012345"
        status:
          type: string
          enum: [pending]
          description: Always `pending` — the worker will move the row to a terminal status on its next claim.
    CreateKeyRequest:
      type: object
      description: Body for `POST /v1/keys`.
      required: [name]
      properties:
        name:
          type: string
          minLength: 1
          maxLength: 64
          description: |
            Human-readable name for this key. Used in the dashboard
            "API keys" view and in `GET /v1/keys`. 1..64 characters.
            Whitespace-only is treated as missing.
          example: "ci-prod"
    KeyCreated:
      type: object
      description: |
        Response from `POST /v1/keys`. The `api_key` field is the raw
        plaintext key and is returned EXACTLY ONCE — store it
        immediately. (Idempotent replay caveat: see the
        `POST /v1/keys` description.)
      required: [id, name, key_prefix, api_key, created_at]
      properties:
        id:
          type: string
          format: uuid
          description: The api_keys row id, used in `DELETE /v1/keys/{id}`.
          example: "8f4c2c4d-8b73-4f71-9e2c-b1ad8f0a9b72"
        name:
          type: string
          example: "ci-prod"
        key_prefix:
          type: string
          description: First 8 hex chars of the key body, with a trailing `...` marker. Safe to display.
          example: "fora_live_a1b2c3d4..."
        api_key:
          type: string
          description: |
            Raw API key, prefixed `fora_live_`. **Returned exactly
            once.** No subsequent endpoint exposes the plaintext.
            (Exception: idempotent replay within the 24h window returns
            the cached response unchanged, including this field — see
            the `POST /v1/keys` description.)
          example: "fora_live_a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f9"
        created_at:
          type: string
          format: date-time
          example: "2026-05-12T19:00:00Z"
    Key:
      type: object
      description: |
        Named-key record as returned by `GET /v1/keys`. The raw
        `api_key` field is intentionally absent — only `key_prefix`
        is included.
      required: [id, name, key_prefix, created_at, last_used_at]
      properties:
        id:
          type: string
          format: uuid
          example: "8f4c2c4d-8b73-4f71-9e2c-b1ad8f0a9b72"
        name:
          type: string
          example: "ci-prod"
        key_prefix:
          type: string
          description: First 8 hex chars of the key body, with a trailing `...` marker. Safe to display.
          example: "fora_live_a1b2c3d4..."
        created_at:
          type: string
          format: date-time
          example: "2026-05-12T19:00:00Z"
        last_used_at:
          type: [string, "null"]
          format: date-time
          description: Last time a request authenticated with this key. Null if the key has never been used.
          example: "2026-05-12T20:11:42Z"
    KeyList:
      type: object
      description: Response from `GET /v1/keys`.
      required: [data]
      properties:
        data:
          type: array
          description: Non-revoked named keys for the calling account.
          items:
            $ref: '#/components/schemas/Key'
    JobSummary:
      type: object
      description: |
        Customer-facing summary of a translation job. Mirrors the
        `internal/store/stats.JobSummary` Go struct that powers both
        `GET /v1/jobs` (TICKET-025) and the dashboard's job-history
        table. The wire shape is snake_case end-to-end and is pinned by
        `TestStatsResponse_StoreTypesSerializeAsSnakeCase`.
      required:
        - id
        - status
        - target_locales
        - has_text
        - has_images
        - created_at
        - completed_at
        - error
        - source_text
        - quality_score
      properties:
        id:
          type: string
          format: uuid
          example: "8f4c2c4d-8b73-4f71-9e2c-b1ad8f0a9b72"
        status:
          $ref: '#/components/schemas/JobStatus'
        target_locales:
          type: array
          items:
            $ref: '#/components/schemas/Locale'
          example: ["es", "fr"]
        has_text:
          type: boolean
          description: True if the job carried `source_text` at creation.
          example: true
        has_images:
          type: boolean
          description: True if the job carried at least one `source_image_urls` entry.
          example: false
        created_at:
          type: string
          format: date-time
          example: "2026-05-12T19:00:00Z"
        completed_at:
          type: [string, "null"]
          format: date-time
          description: When the job reached `completed` or `failed`. Null while still pending or processing.
          example: "2026-05-12T19:00:04Z"
        error:
          type: [string, "null"]
          description: Failure reason if `status` is `failed`. Null otherwise.
          example: null
        source_text:
          type: string
          description: |
            The full original source text (not truncated server-side).
            Empty string for image-only jobs.
          example: "Welcome to our marketplace."
        quality_score:
          type: [number, "null"]
          format: float
          minimum: 0
          maximum: 1
          description: |
            Average per-locale LLM-as-judge quality score. **Null**
            (not 0) for jobs that have no scored translations yet
            (still in flight, image-only, or scoring failed for every
            locale). Clients distinguish "not scored" from "scored
            zero" by `null` vs. number.
          example: 0.92
    JobsList:
      type: object
      description: |
        Cursor-paginated response from `GET /v1/jobs`. The
        `next_cursor` field is **omitted (not null)** when `has_more`
        is `false` — callers should branch on key presence, not on
        a null value. Stripe-style.
      required: [data, has_more]
      properties:
        data:
          type: array
          description: Jobs on this page, newest first.
          items:
            $ref: '#/components/schemas/JobSummary'
        has_more:
          type: boolean
          description: True if at least one row exists beyond this page.
          example: true
        next_cursor:
          type: string
          description: |
            Opaque pagination token. Pass back as `?cursor=...` to
            fetch the next page. **Omitted** when `has_more` is
            `false`.
          example: "eyJjIjoiMjAyNi0wNS0xMlQxOTowMDowMFoiLCJpIjoiOGY0YzJjNGQtOGI3My00ZjcxLTllMmMtYjFhZDhmMGE5YjcyIn0"
    JobDetailLocaleRow:
      type: object
      description: |
        One per-locale translation result inlined inside `JobDetail.translations`.
        `translated_text` is null until the worker stores a result for the
        locale; `translated_image_urls` is an empty array on text-only jobs
        and a one-element array on image jobs that wrote to either Fora's
        bucket or a customer-owned `image_destination_url`.
      required: [locale, translated_text, translated_image_urls, quality_score]
      properties:
        locale:
          $ref: '#/components/schemas/Locale'
        translated_text:
          type: [string, "null"]
          description: |
            Translated string for this locale. Null if the locale has not
            been translated yet (job still pending/processing) or if it is
            an image-only job. Empty string is a legitimate translation
            output and is distinct from null.
          example: "Bienvenido a nuestro mercado."
        translated_image_urls:
          type: array
          description: |
            HTTPS URLs of the translated images for this locale. Empty
            for text-only jobs. For jobs that used
            `image_destination_url`, this is the customer-owned URL.
          items:
            type: string
            format: uri
          example: []
        quality_score:
          type: [number, "null"]
          format: float
          minimum: 0
          maximum: 1
          description: |
            LLM-as-judge quality score for this locale's translation.
            **Null** (not 0) if the locale has not been scored yet
            (worker still in flight, image-only job, or scoring failed).
            Distinguish "not scored" from "scored zero" by null vs.
            number.
          example: null
    JobDetail:
      type: object
      description: |
        Full record for a single translation job, with per-locale
        results inlined under `translations`. Returned by
        `GET /v1/jobs/{id}` (TICKET-026). Stripe-style "expanded
        resource" — readers can branch on `status` plus the
        `translations` array without issuing a second round trip per
        locale.

        Snake_case end-to-end. The wire shape is pinned by
        `TestJobDetail_StoreTypesSerializeAsSnakeCase` in
        `internal/api/jobs_get_test.go`.
      required:
        - id
        - status
        - source_text
        - source_image_urls
        - target_locales
        - webhook_url
        - image_destination_url
        - protected_terms
        - custom_instructions
        - created_at
        - completed_at
        - error
        - translations
      properties:
        id:
          type: string
          format: uuid
          example: "8f4c2c4d-8b73-4f71-9e2c-b1ad8f0a9b72"
        status:
          $ref: '#/components/schemas/JobStatus'
        source_text:
          type: [string, "null"]
          description: Original source text. Null for image-only jobs.
          example: "Welcome to our marketplace."
        source_image_urls:
          type: array
          description: Original source-image URLs. Empty array for text-only jobs.
          items:
            type: string
            format: uri
          example: []
        target_locales:
          type: array
          items:
            $ref: '#/components/schemas/Locale'
          example: ["es", "fr"]
        webhook_url:
          type: [string, "null"]
          format: uri
          description: Optional webhook the job posted/will post to on completion.
          example: "https://example.com/webhooks/fora"
        image_destination_url:
          type: [string, "null"]
          format: uri
          description: Customer-owned S3 destination for image jobs, when set.
          example: null
        protected_terms:
          type: array
          description: Glossary terms preserved verbatim during translation.
          items:
            type: string
          example: ["Acme"]
        custom_instructions:
          type: [string, "null"]
          description: Free-form translation guidance forwarded to the model.
          example: "Use a friendly, marketing tone."
        created_at:
          type: string
          format: date-time
          example: "2026-05-12T19:00:00Z"
        completed_at:
          type: [string, "null"]
          format: date-time
          description: When the job reached `completed` or `failed`. Null while still pending or processing.
          example: "2026-05-12T19:00:04Z"
        error:
          type: [string, "null"]
          description: Failure reason if `status` is `failed`. Null otherwise.
          example: null
        translations:
          type: array
          description: |
            Per-locale translation results. Empty while the job is still
            pending; partial while in flight; complete on terminal
            states. Locale ordering matches the database read order —
            sort client-side if you need a stable order.
          items:
            $ref: '#/components/schemas/JobDetailLocaleRow'
    Connector:
      type: object
      description: |
        Last-known heartbeat for one connector instance. Mirrors the
        `internal/store/heartbeat.ConnectorHeartbeat` Go struct; the
        wire shape is snake_case end-to-end and is pinned by
        `TestStatsResponse_StoreTypesSerializeAsSnakeCase`.
      required: [connector_name, synced_at, rows_synced, rows_failed, backlog]
      properties:
        connector_name:
          type: string
          description: Stable identifier for the connector instance.
          example: "products-prod"
        synced_at:
          type: string
          format: date-time
          description: When the connector last reported a heartbeat.
          example: "2026-05-12T19:00:00Z"
        rows_synced:
          type: integer
          format: int64
          minimum: 0
          description: Total rows successfully synced in the last cycle.
          example: 1284
        rows_failed:
          type: integer
          format: int64
          minimum: 0
          description: Total rows that failed in the last cycle.
          example: 0
        backlog:
          type: integer
          format: int64
          minimum: 0
          description: Approximate number of rows still awaiting translation.
          example: 0
    ConnectorList:
      type: object
      description: Response from `GET /v1/connectors`.
      required: [data]
      properties:
        data:
          type: array
          description: Connectors for the calling account, newest heartbeat first.
          items:
            $ref: '#/components/schemas/Connector'
    HeartbeatRequest:
      type: object
      description: Body for `POST /v1/accounts/me/connector/heartbeat`.
      required: [connector_name]
      properties:
        connector_name:
          type: string
          description: |
            Stable identifier for the connector instance. Reusing the same
            name from the same account upserts the existing record.
          example: "products-prod"
        rows_synced:
          type: integer
          format: int64
          minimum: 0
          description: Total rows successfully synced this cycle.
          example: 1284
        rows_failed:
          type: integer
          format: int64
          minimum: 0
          description: Total rows that failed this cycle.
          example: 0
        backlog:
          type: integer
          format: int64
          minimum: 0
          description: Approximate number of rows still awaiting translation.
          example: 0
    DailyUsage:
      type: object
      description: |
        One day of usage totals. Element type of `UsageResponse.daily`. The
        same shape backs the dashboard's sparkline and is pinned by
        `TestStatsResponse_StoreTypesSerializeAsSnakeCase` in
        `internal/api/stats_test.go`.
      required:
        - date
        - chars_processed
        - images_processed
      properties:
        date:
          type: string
          format: date
          description: Calendar date in UTC, `YYYY-MM-DD`.
          example: "2026-05-12"
        chars_processed:
          type: integer
          format: int64
          minimum: 0
          description: Characters translated on this date.
          example: 12345
        images_processed:
          type: integer
          format: int64
          minimum: 0
          description: Images translated on this date.
          example: 7
    UsageResponse:
      type: object
      description: |
        Response shape for `GET /v1/usage`. `chars_limit` and `images_limit`
        are `null` for plans the server does not enforce a quota against
        (today: every plan other than `free`). The `daily` series always has
        exactly 30 entries (or fewer for accounts younger than 30 days),
        ordered ascending by date, with zero-usage days included as zero
        rows so client chart code never has to interpolate missing dates.
      required:
        - chars_used
        - chars_limit
        - images_used
        - images_limit
        - plan
        - period_start
        - period_end
        - daily
      properties:
        chars_used:
          type: integer
          format: int64
          minimum: 0
          description: Characters translated this period.
          example: 12345
        chars_limit:
          type: [integer, "null"]
          format: int64
          minimum: 0
          description: |
            Hard monthly character cap for this plan, or `null` if the
            server does not enforce one.
          example: 10000000
        images_used:
          type: integer
          format: int64
          minimum: 0
          description: Images translated this period.
          example: 7
        images_limit:
          type: [integer, "null"]
          format: int64
          minimum: 0
          description: |
            Hard monthly image cap for this plan, or `null` if the server
            does not enforce one.
          example: 2000
        plan:
          type: string
          description: Plan slug for the calling account.
          enum: [free, starter, pro, enterprise]
          example: free
        period_start:
          type: string
          format: date-time
          description: First instant of the current calendar-month billing period (UTC).
          example: "2026-05-01T00:00:00Z"
        period_end:
          type: string
          format: date-time
          description: |
            First instant of the next calendar month (exclusive upper bound),
            matching the half-open convention used internally by Fora's
            billing accounting.
          example: "2026-06-01T00:00:00Z"
        daily:
          type: array
          description: |
            Per-day usage rows for the trailing 30 days, ascending by date.
            Always exactly 30 entries (or fewer for younger accounts);
            zero-usage days are present, not omitted.
          items:
            $ref: '#/components/schemas/DailyUsage'
