{
  "components": {
    "responses": {
      "InternalError": {
        "content": {
          "application/json": {
            "examples": {
              "internal": {
                "value": {
                  "error": "failed to create job",
                  "error_details": {
                    "code": "failed_create_job",
                    "request_id": "req_01HX9PT2W4Y6NJZAB3CDEF0070",
                    "type": "internal_error"
                  }
                }
              }
            },
            "schema": {
              "$ref": "#/components/schemas/ErrorResponse"
            }
          }
        },
        "description": "Unhandled server-side error. The `request_id` should be quoted in any support ticket."
      },
      "Unauthorized": {
        "content": {
          "application/json": {
            "examples": {
              "invalid": {
                "value": {
                  "error": "invalid API key",
                  "error_details": {
                    "code": "invalid_api_key",
                    "request_id": "req_01HX9PT2W4Y6NJZAB3CDEF0061",
                    "type": "authentication_error"
                  }
                }
              },
              "missing": {
                "value": {
                  "error": "missing API key",
                  "error_details": {
                    "code": "missing_api_key",
                    "request_id": "req_01HX9PT2W4Y6NJZAB3CDEF0060",
                    "type": "authentication_error"
                  }
                }
              }
            },
            "schema": {
              "$ref": "#/components/schemas/ErrorResponse"
            }
          }
        },
        "description": "Missing, malformed, or invalid API key."
      }
    },
    "schemas": {
      "Connector": {
        "description": "Last-known heartbeat for one connector instance. Mirrors the\n`internal/store/heartbeat.ConnectorHeartbeat` Go struct; the\nwire shape is snake_case end-to-end and is pinned by\n`TestStatsResponse_StoreTypesSerializeAsSnakeCase`.\n",
        "properties": {
          "backlog": {
            "description": "Approximate number of rows still awaiting translation.",
            "example": 0,
            "format": "int64",
            "minimum": 0,
            "type": "integer"
          },
          "connector_name": {
            "description": "Stable identifier for the connector instance.",
            "example": "products-prod",
            "type": "string"
          },
          "rows_failed": {
            "description": "Total rows that failed in the last cycle.",
            "example": 0,
            "format": "int64",
            "minimum": 0,
            "type": "integer"
          },
          "rows_synced": {
            "description": "Total rows successfully synced in the last cycle.",
            "example": 1284,
            "format": "int64",
            "minimum": 0,
            "type": "integer"
          },
          "synced_at": {
            "description": "When the connector last reported a heartbeat.",
            "example": "2026-05-12T19:00:00Z",
            "format": "date-time",
            "type": "string"
          }
        },
        "required": [
          "connector_name",
          "synced_at",
          "rows_synced",
          "rows_failed",
          "backlog"
        ],
        "type": "object"
      },
      "ConnectorList": {
        "description": "Response from `GET /v1/connectors`.",
        "properties": {
          "data": {
            "description": "Connectors for the calling account, newest heartbeat first.",
            "items": {
              "$ref": "#/components/schemas/Connector"
            },
            "type": "array"
          }
        },
        "required": [
          "data"
        ],
        "type": "object"
      },
      "Content": {
        "description": "Translated content for one locale of a completed job.",
        "properties": {
          "job_id": {
            "example": "550e8400-e29b-41d4-a716-446655440000",
            "format": "uuid",
            "type": "string"
          },
          "locale": {
            "$ref": "#/components/schemas/Locale"
          },
          "status": {
            "$ref": "#/components/schemas/JobStatus"
          },
          "translated_image_urls": {
            "description": "HTTPS URLs of the translated images. For jobs that used\n`image_destination_url`, this is the customer-owned URL.\nOtherwise it is a Fora-hosted URL.\n",
            "example": [
              "https://my-bucket.s3.us-east-1.amazonaws.com/banners/welcome-fr.jpg"
            ],
            "items": {
              "format": "uri",
              "type": "string"
            },
            "type": "array"
          },
          "translated_text": {
            "description": "The translated string. Empty for image-only jobs.",
            "example": "Bienvenido a nuestro mercado.",
            "type": "string"
          }
        },
        "required": [
          "job_id",
          "locale",
          "status",
          "translated_text",
          "translated_image_urls"
        ],
        "type": "object"
      },
      "CreateAccountRequest": {
        "description": "Body for `POST /v1/accounts`.",
        "properties": {
          "email": {
            "description": "Email address for the new account. Lowercased server-side.",
            "example": "engineer@example.com",
            "format": "email",
            "type": "string"
          }
        },
        "required": [
          "email"
        ],
        "type": "object"
      },
      "CreateAccountResponse": {
        "description": "Response from `POST /v1/accounts`. The `api_key` is shown exactly once.",
        "properties": {
          "account_id": {
            "example": "5d4e2c4d-8b73-4f71-9e2c-b1ad8f0a9b72",
            "format": "uuid",
            "type": "string"
          },
          "api_key": {
            "description": "Raw API key. Use as `Authorization: Bearer \u003capi_key\u003e`.",
            "example": "fora_live_p4ZxQy8k3J9vR2nLmWd1cFxV6tHs0aB7",
            "type": "string"
          },
          "message": {
            "example": "Your API key has been sent to engineer@example.com. Store it somewhere safe.",
            "type": "string"
          }
        },
        "required": [
          "account_id",
          "api_key",
          "message"
        ],
        "type": "object"
      },
      "CreateKeyRequest": {
        "description": "Body for `POST /v1/keys`.",
        "properties": {
          "name": {
            "description": "Human-readable name for this key. Used in the dashboard\n\"API keys\" view and in `GET /v1/keys`. 1..64 characters.\nWhitespace-only is treated as missing.\n",
            "example": "ci-prod",
            "maxLength": 64,
            "minLength": 1,
            "type": "string"
          }
        },
        "required": [
          "name"
        ],
        "type": "object"
      },
      "CreateWebhookRequest": {
        "description": "Body for `POST /v1/webhooks`.",
        "properties": {
          "signed": {
            "default": true,
            "description": "Whether outbound deliveries to this webhook should carry the\nHMAC-SHA256 signature header (computed from the per-webhook\nsecret returned at creation). Defaults to `true`. Set to\n`false` only for legacy receivers that cannot verify the\nsignature. EPIC-005 / TICKET-017 ships the actual signing.\n",
            "example": true,
            "type": "boolean"
          },
          "url": {
            "description": "HTTP or HTTPS URL the webhook will POST to. Must include a\nvalid host. Same validation as `webhook_url` on\n`POST /v1/translate`.\n",
            "example": "https://example.com/webhooks/fora",
            "format": "uri",
            "type": "string"
          }
        },
        "required": [
          "url"
        ],
        "type": "object"
      },
      "DailyUsage": {
        "description": "One day of usage totals. Element type of `UsageResponse.daily`. The\nsame shape backs the dashboard's sparkline and is pinned by\n`TestStatsResponse_StoreTypesSerializeAsSnakeCase` in\n`internal/api/stats_test.go`.\n",
        "properties": {
          "chars_processed": {
            "description": "Characters translated on this date.",
            "example": 12345,
            "format": "int64",
            "minimum": 0,
            "type": "integer"
          },
          "date": {
            "description": "Calendar date in UTC, `YYYY-MM-DD`.",
            "example": "2026-05-12",
            "format": "date",
            "type": "string"
          },
          "images_processed": {
            "description": "Images translated on this date.",
            "example": 7,
            "format": "int64",
            "minimum": 0,
            "type": "integer"
          }
        },
        "required": [
          "date",
          "chars_processed",
          "images_processed"
        ],
        "type": "object"
      },
      "ErrorCode": {
        "description": "Granular machine-readable error code. New codes are additive within\nan API version. The full catalogue lives in `internal/api/codes.go`\nin the [getfora/fora](https://github.com/getfora/fora) repo.\n",
        "example": "missing_target_locales",
        "type": "string"
      },
      "ErrorDetails": {
        "description": "Structured error payload. Stable wire contract from TICKET-011.",
        "properties": {
          "code": {
            "$ref": "#/components/schemas/ErrorCode"
          },
          "param": {
            "description": "The name of the request field that caused the error, when the\nerror is field-scoped. Omitted otherwise.\n",
            "example": "target_locales",
            "type": "string"
          },
          "request_id": {
            "description": "The server-assigned request id. Quote this when contacting\nsupport — every log line for the request carries the same id.\n",
            "example": "req_01HX9PT2W4Y6NJZAB3CDEF0001",
            "type": "string"
          },
          "type": {
            "$ref": "#/components/schemas/ErrorType"
          }
        },
        "required": [
          "type",
          "code",
          "request_id"
        ],
        "type": "object"
      },
      "ErrorResponse": {
        "description": "Canonical error response body. The top-level `error` field is a\nhuman-readable string preserved for legacy SDKs; new code should\nbranch on `error_details.type` and `error_details.code`.\n",
        "properties": {
          "error": {
            "description": "Human-readable error message. Safe to surface to end users.",
            "example": "target_locales is required",
            "type": "string"
          },
          "error_details": {
            "$ref": "#/components/schemas/ErrorDetails"
          }
        },
        "required": [
          "error",
          "error_details"
        ],
        "type": "object"
      },
      "ErrorType": {
        "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",
        "type": "string"
      },
      "HeartbeatRequest": {
        "description": "Body for `POST /v1/accounts/me/connector/heartbeat`.",
        "properties": {
          "backlog": {
            "description": "Approximate number of rows still awaiting translation.",
            "example": 0,
            "format": "int64",
            "minimum": 0,
            "type": "integer"
          },
          "connector_name": {
            "description": "Stable identifier for the connector instance. Reusing the same\nname from the same account upserts the existing record.\n",
            "example": "products-prod",
            "type": "string"
          },
          "rows_failed": {
            "description": "Total rows that failed this cycle.",
            "example": 0,
            "format": "int64",
            "minimum": 0,
            "type": "integer"
          },
          "rows_synced": {
            "description": "Total rows successfully synced this cycle.",
            "example": 1284,
            "format": "int64",
            "minimum": 0,
            "type": "integer"
          }
        },
        "required": [
          "connector_name"
        ],
        "type": "object"
      },
      "JobAccepted": {
        "description": "Response from `POST /v1/translate` when the job is queued.",
        "properties": {
          "job_id": {
            "description": "The id used for polling and webhook correlation.",
            "example": "550e8400-e29b-41d4-a716-446655440000",
            "format": "uuid",
            "type": "string"
          },
          "status": {
            "$ref": "#/components/schemas/JobStatus"
          }
        },
        "required": [
          "job_id",
          "status"
        ],
        "type": "object"
      },
      "JobDetail": {
        "description": "Full record for a single translation job, with per-locale\nresults inlined under `translations`. Returned by\n`GET /v1/jobs/{id}` (TICKET-026). Stripe-style \"expanded\nresource\" — readers can branch on `status` plus the\n`translations` array without issuing a second round trip per\nlocale.\n\nSnake_case end-to-end. The wire shape is pinned by\n`TestJobDetail_StoreTypesSerializeAsSnakeCase` in\n`internal/api/jobs_get_test.go`.\n",
        "properties": {
          "completed_at": {
            "description": "When the job reached `completed` or `failed`. Null while still pending or processing.",
            "example": "2026-05-12T19:00:04Z",
            "format": "date-time",
            "type": [
              "string",
              "null"
            ]
          },
          "created_at": {
            "example": "2026-05-12T19:00:00Z",
            "format": "date-time",
            "type": "string"
          },
          "custom_instructions": {
            "description": "Free-form translation guidance forwarded to the model.",
            "example": "Use a friendly, marketing tone.",
            "type": [
              "string",
              "null"
            ]
          },
          "error": {
            "description": "Failure reason if `status` is `failed`. Null otherwise.",
            "example": null,
            "type": [
              "string",
              "null"
            ]
          },
          "id": {
            "example": "8f4c2c4d-8b73-4f71-9e2c-b1ad8f0a9b72",
            "format": "uuid",
            "type": "string"
          },
          "image_destination_url": {
            "description": "Customer-owned S3 destination for image jobs, when set.",
            "example": null,
            "format": "uri",
            "type": [
              "string",
              "null"
            ]
          },
          "protected_terms": {
            "description": "Glossary terms preserved verbatim during translation.",
            "example": [
              "Acme"
            ],
            "items": {
              "type": "string"
            },
            "type": "array"
          },
          "source_image_urls": {
            "description": "Original source-image URLs. Empty array for text-only jobs.",
            "example": [],
            "items": {
              "format": "uri",
              "type": "string"
            },
            "type": "array"
          },
          "source_text": {
            "description": "Original source text. Null for image-only jobs.",
            "example": "Welcome to our marketplace.",
            "type": [
              "string",
              "null"
            ]
          },
          "status": {
            "$ref": "#/components/schemas/JobStatus"
          },
          "target_locales": {
            "example": [
              "es",
              "fr"
            ],
            "items": {
              "$ref": "#/components/schemas/Locale"
            },
            "type": "array"
          },
          "translations": {
            "description": "Per-locale translation results. Empty while the job is still\npending; partial while in flight; complete on terminal\nstates. Locale ordering matches the database read order —\nsort client-side if you need a stable order.\n",
            "items": {
              "$ref": "#/components/schemas/JobDetailLocaleRow"
            },
            "type": "array"
          },
          "webhook_url": {
            "description": "Optional webhook the job posted/will post to on completion.",
            "example": "https://example.com/webhooks/fora",
            "format": "uri",
            "type": [
              "string",
              "null"
            ]
          }
        },
        "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"
        ],
        "type": "object"
      },
      "JobDetailLocaleRow": {
        "description": "One per-locale translation result inlined inside `JobDetail.translations`.\n`translated_text` is null until the worker stores a result for the\nlocale; `translated_image_urls` is an empty array on text-only jobs\nand a one-element array on image jobs that wrote to either Fora's\nbucket or a customer-owned `image_destination_url`.\n",
        "properties": {
          "locale": {
            "$ref": "#/components/schemas/Locale"
          },
          "quality_score": {
            "description": "LLM-as-judge quality score for this locale's translation.\n**Null** (not 0) if the locale has not been scored yet\n(worker still in flight, image-only job, or scoring failed).\nDistinguish \"not scored\" from \"scored zero\" by null vs.\nnumber.\n",
            "example": null,
            "format": "float",
            "maximum": 1,
            "minimum": 0,
            "type": [
              "number",
              "null"
            ]
          },
          "translated_image_urls": {
            "description": "HTTPS URLs of the translated images for this locale. Empty\nfor text-only jobs. For jobs that used\n`image_destination_url`, this is the customer-owned URL.\n",
            "example": [],
            "items": {
              "format": "uri",
              "type": "string"
            },
            "type": "array"
          },
          "translated_text": {
            "description": "Translated string for this locale. Null if the locale has not\nbeen translated yet (job still pending/processing) or if it is\nan image-only job. Empty string is a legitimate translation\noutput and is distinct from null.\n",
            "example": "Bienvenido a nuestro mercado.",
            "type": [
              "string",
              "null"
            ]
          }
        },
        "required": [
          "locale",
          "translated_text",
          "translated_image_urls",
          "quality_score"
        ],
        "type": "object"
      },
      "JobInProgress": {
        "description": "Response from `GET /v1/content/{id}` while a job is still pending or has failed.",
        "properties": {
          "error": {
            "description": "Failure reason if `status` is `failed`. Empty otherwise.",
            "example": "",
            "type": "string"
          },
          "job_id": {
            "example": "550e8400-e29b-41d4-a716-446655440000",
            "format": "uuid",
            "type": "string"
          },
          "status": {
            "$ref": "#/components/schemas/JobStatus"
          }
        },
        "required": [
          "job_id",
          "status"
        ],
        "type": "object"
      },
      "JobStatus": {
        "description": "Lifecycle state of a translation job.",
        "enum": [
          "pending",
          "processing",
          "completed",
          "failed"
        ],
        "example": "pending",
        "type": "string"
      },
      "JobSummary": {
        "description": "Customer-facing summary of a translation job. Mirrors the\n`internal/store/stats.JobSummary` Go struct that powers both\n`GET /v1/jobs` (TICKET-025) and the dashboard's job-history\ntable. The wire shape is snake_case end-to-end and is pinned by\n`TestStatsResponse_StoreTypesSerializeAsSnakeCase`.\n",
        "properties": {
          "completed_at": {
            "description": "When the job reached `completed` or `failed`. Null while still pending or processing.",
            "example": "2026-05-12T19:00:04Z",
            "format": "date-time",
            "type": [
              "string",
              "null"
            ]
          },
          "created_at": {
            "example": "2026-05-12T19:00:00Z",
            "format": "date-time",
            "type": "string"
          },
          "error": {
            "description": "Failure reason if `status` is `failed`. Null otherwise.",
            "example": null,
            "type": [
              "string",
              "null"
            ]
          },
          "has_images": {
            "description": "True if the job carried at least one `source_image_urls` entry.",
            "example": false,
            "type": "boolean"
          },
          "has_text": {
            "description": "True if the job carried `source_text` at creation.",
            "example": true,
            "type": "boolean"
          },
          "id": {
            "example": "8f4c2c4d-8b73-4f71-9e2c-b1ad8f0a9b72",
            "format": "uuid",
            "type": "string"
          },
          "quality_score": {
            "description": "Average per-locale LLM-as-judge quality score. **Null**\n(not 0) for jobs that have no scored translations yet\n(still in flight, image-only, or scoring failed for every\nlocale). Clients distinguish \"not scored\" from \"scored\nzero\" by `null` vs. number.\n",
            "example": 0.92,
            "format": "float",
            "maximum": 1,
            "minimum": 0,
            "type": [
              "number",
              "null"
            ]
          },
          "source_text": {
            "description": "The full original source text (not truncated server-side).\nEmpty string for image-only jobs.\n",
            "example": "Welcome to our marketplace.",
            "type": "string"
          },
          "status": {
            "$ref": "#/components/schemas/JobStatus"
          },
          "target_locales": {
            "example": [
              "es",
              "fr"
            ],
            "items": {
              "$ref": "#/components/schemas/Locale"
            },
            "type": "array"
          }
        },
        "required": [
          "id",
          "status",
          "target_locales",
          "has_text",
          "has_images",
          "created_at",
          "completed_at",
          "error",
          "source_text",
          "quality_score"
        ],
        "type": "object"
      },
      "JobsList": {
        "description": "Cursor-paginated response from `GET /v1/jobs`. The\n`next_cursor` field is **omitted (not null)** when `has_more`\nis `false` — callers should branch on key presence, not on\na null value. Stripe-style.\n",
        "properties": {
          "data": {
            "description": "Jobs on this page, newest first.",
            "items": {
              "$ref": "#/components/schemas/JobSummary"
            },
            "type": "array"
          },
          "has_more": {
            "description": "True if at least one row exists beyond this page.",
            "example": true,
            "type": "boolean"
          },
          "next_cursor": {
            "description": "Opaque pagination token. Pass back as `?cursor=...` to\nfetch the next page. **Omitted** when `has_more` is\n`false`.\n",
            "example": "eyJjIjoiMjAyNi0wNS0xMlQxOTowMDowMFoiLCJpIjoiOGY0YzJjNGQtOGI3My00ZjcxLTllMmMtYjFhZDhmMGE5YjcyIn0",
            "type": "string"
          }
        },
        "required": [
          "data",
          "has_more"
        ],
        "type": "object"
      },
      "Key": {
        "description": "Named-key record as returned by `GET /v1/keys`. The raw\n`api_key` field is intentionally absent — only `key_prefix`\nis included.\n",
        "properties": {
          "created_at": {
            "example": "2026-05-12T19:00:00Z",
            "format": "date-time",
            "type": "string"
          },
          "id": {
            "example": "8f4c2c4d-8b73-4f71-9e2c-b1ad8f0a9b72",
            "format": "uuid",
            "type": "string"
          },
          "key_prefix": {
            "description": "First 8 hex chars of the key body, with a trailing `...` marker. Safe to display.",
            "example": "fora_live_a1b2c3d4...",
            "type": "string"
          },
          "last_used_at": {
            "description": "Last time a request authenticated with this key. Null if the key has never been used.",
            "example": "2026-05-12T20:11:42Z",
            "format": "date-time",
            "type": [
              "string",
              "null"
            ]
          },
          "name": {
            "example": "ci-prod",
            "type": "string"
          }
        },
        "required": [
          "id",
          "name",
          "key_prefix",
          "created_at",
          "last_used_at"
        ],
        "type": "object"
      },
      "KeyCreated": {
        "description": "Response from `POST /v1/keys`. The `api_key` field is the raw\nplaintext key and is returned EXACTLY ONCE — store it\nimmediately. (Idempotent replay caveat: see the\n`POST /v1/keys` description.)\n",
        "properties": {
          "api_key": {
            "description": "Raw API key, prefixed `fora_live_`. **Returned exactly\nonce.** No subsequent endpoint exposes the plaintext.\n(Exception: idempotent replay within the 24h window returns\nthe cached response unchanged, including this field — see\nthe `POST /v1/keys` description.)\n",
            "example": "fora_live_a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f9",
            "type": "string"
          },
          "created_at": {
            "example": "2026-05-12T19:00:00Z",
            "format": "date-time",
            "type": "string"
          },
          "id": {
            "description": "The api_keys row id, used in `DELETE /v1/keys/{id}`.",
            "example": "8f4c2c4d-8b73-4f71-9e2c-b1ad8f0a9b72",
            "format": "uuid",
            "type": "string"
          },
          "key_prefix": {
            "description": "First 8 hex chars of the key body, with a trailing `...` marker. Safe to display.",
            "example": "fora_live_a1b2c3d4...",
            "type": "string"
          },
          "name": {
            "example": "ci-prod",
            "type": "string"
          }
        },
        "required": [
          "id",
          "name",
          "key_prefix",
          "api_key",
          "created_at"
        ],
        "type": "object"
      },
      "KeyList": {
        "description": "Response from `GET /v1/keys`.",
        "properties": {
          "data": {
            "description": "Non-revoked named keys for the calling account.",
            "items": {
              "$ref": "#/components/schemas/Key"
            },
            "type": "array"
          }
        },
        "required": [
          "data"
        ],
        "type": "object"
      },
      "Locale": {
        "description": "A supported target locale.",
        "enum": [
          "de",
          "es",
          "fr",
          "it",
          "pt-BR"
        ],
        "example": "es",
        "type": "string"
      },
      "QuickstartResponse": {
        "description": "Response from `POST /v1/quickstart`.",
        "properties": {
          "api_key": {
            "description": "Raw API key for the demo account.",
            "example": "fora_live_q9vR2nLmWd1cFxV6tHs0aB7p4ZxQy8k3",
            "type": "string"
          },
          "examples": {
            "description": "Pre-rendered `curl` snippets bound to this account's key and the current host.",
            "properties": {
              "get_result": {
                "description": "Curl snippet for `GET /v1/content/{job_id}?locale=es`.",
                "type": "string"
              },
              "translate_image": {
                "description": "Curl snippet for `POST /v1/translate` with an image.",
                "type": "string"
              },
              "translate_text": {
                "description": "Curl snippet for `POST /v1/translate` with text.",
                "type": "string"
              }
            },
            "required": [
              "translate_text",
              "translate_image",
              "get_result"
            ],
            "type": "object"
          }
        },
        "required": [
          "api_key",
          "examples"
        ],
        "type": "object"
      },
      "StatusOK": {
        "properties": {
          "status": {
            "example": "ok",
            "type": "string"
          }
        },
        "required": [
          "status"
        ],
        "type": "object"
      },
      "StorageConfigRequest": {
        "description": "Body for `POST /v1/accounts/storage`.",
        "properties": {
          "access_key_id": {
            "description": "AWS access key id with `s3:PutObject` on the destination bucket.",
            "example": "AKIAIOSFODNN7EXAMPLE",
            "type": "string"
          },
          "bucket_name": {
            "description": "Destination S3 bucket name.",
            "example": "my-bucket",
            "type": "string"
          },
          "region": {
            "description": "AWS region of the bucket.",
            "example": "us-east-1",
            "type": "string"
          },
          "secret_access_key": {
            "description": "Corresponding AWS secret access key. KMS-encrypted at rest.",
            "example": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
            "type": "string"
          }
        },
        "required": [
          "bucket_name",
          "region",
          "access_key_id",
          "secret_access_key"
        ],
        "type": "object"
      },
      "TranslateRequest": {
        "description": "Body for `POST /v1/translate`.",
        "properties": {
          "custom_instructions": {
            "description": "Free-form translation guidance, up to 500 characters. Forwarded\nto the model as a system instruction.\n",
            "example": "Use a friendly, marketing tone.",
            "maxLength": 500,
            "type": "string"
          },
          "image_destination_url": {
            "description": "Optional `https` S3 object URL where the translated image will\nbe written. Requires exactly one source image and one target\nlocale. The bucket credentials must already be saved via\n`POST /v1/accounts/storage`.\n",
            "example": "https://my-bucket.s3.us-east-1.amazonaws.com/banners/welcome-fr.jpg",
            "format": "uri",
            "type": "string"
          },
          "protected_terms": {
            "description": "Glossary of terms that must NOT be translated. Each term is at\nmost 100 characters; up to 50 terms per request.\n",
            "example": [
              "Acme",
              "Acme Pro"
            ],
            "items": {
              "maxLength": 100,
              "type": "string"
            },
            "maxItems": 50,
            "type": "array"
          },
          "source_image_urls": {
            "description": "HTTP(S) URLs of source images. Required if `source_text` is empty.\nEach image is fetched server-side and translated independently.\n",
            "example": [
              "https://cdn.example.com/banners/welcome-en.jpg"
            ],
            "items": {
              "format": "uri",
              "type": "string"
            },
            "type": "array"
          },
          "source_text": {
            "description": "The text to translate. Required if `source_image_urls` is empty.",
            "example": "Welcome to our marketplace.",
            "type": "string"
          },
          "target_locales": {
            "description": "Locales to translate into. At least one is required.",
            "example": [
              "es",
              "fr"
            ],
            "items": {
              "$ref": "#/components/schemas/Locale"
            },
            "minItems": 1,
            "type": "array"
          },
          "webhook_url": {
            "description": "Optional `http(s)` URL that receives a POST with the completed\njob. Deliveries to a URL that has NOT been registered via\n`POST /v1/webhooks` are unsigned (the server auto-creates a\n`signed: false` webhook row on first delivery so subsequent\njobs reuse the same record). To get HMAC-signed deliveries\nwith the `Fora-Signature` header, register the URL via\n`POST /v1/webhooks` first and pass the same URL here. The\n`Fora-Event-Id` dedupe header is set on every delivery\nregardless of signing — see the `Webhooks` tag description\nfor the full header contract.\n",
            "example": "https://example.com/webhooks/fora",
            "format": "uri",
            "type": "string"
          }
        },
        "type": "object"
      },
      "UsageResponse": {
        "description": "Response shape for `GET /v1/usage`. `chars_limit` and `images_limit`\nare `null` for plans the server does not enforce a quota against\n(today: every plan other than `free`). The `daily` series always has\nexactly 30 entries (or fewer for accounts younger than 30 days),\nordered ascending by date, with zero-usage days included as zero\nrows so client chart code never has to interpolate missing dates.\n",
        "properties": {
          "chars_limit": {
            "description": "Hard monthly character cap for this plan, or `null` if the\nserver does not enforce one.\n",
            "example": 10000000,
            "format": "int64",
            "minimum": 0,
            "type": [
              "integer",
              "null"
            ]
          },
          "chars_used": {
            "description": "Characters translated this period.",
            "example": 12345,
            "format": "int64",
            "minimum": 0,
            "type": "integer"
          },
          "daily": {
            "description": "Per-day usage rows for the trailing 30 days, ascending by date.\nAlways exactly 30 entries (or fewer for younger accounts);\nzero-usage days are present, not omitted.\n",
            "items": {
              "$ref": "#/components/schemas/DailyUsage"
            },
            "type": "array"
          },
          "images_limit": {
            "description": "Hard monthly image cap for this plan, or `null` if the server\ndoes not enforce one.\n",
            "example": 2000,
            "format": "int64",
            "minimum": 0,
            "type": [
              "integer",
              "null"
            ]
          },
          "images_used": {
            "description": "Images translated this period.",
            "example": 7,
            "format": "int64",
            "minimum": 0,
            "type": "integer"
          },
          "period_end": {
            "description": "First instant of the next calendar month (exclusive upper bound),\nmatching the half-open convention used internally by Fora's\nbilling accounting.\n",
            "example": "2026-06-01T00:00:00Z",
            "format": "date-time",
            "type": "string"
          },
          "period_start": {
            "description": "First instant of the current calendar-month billing period (UTC).",
            "example": "2026-05-01T00:00:00Z",
            "format": "date-time",
            "type": "string"
          },
          "plan": {
            "description": "Plan slug for the calling account.",
            "enum": [
              "free",
              "starter",
              "pro",
              "enterprise"
            ],
            "example": "free",
            "type": "string"
          }
        },
        "required": [
          "chars_used",
          "chars_limit",
          "images_used",
          "images_limit",
          "plan",
          "period_start",
          "period_end",
          "daily"
        ],
        "type": "object"
      },
      "Webhook": {
        "description": "Webhook record as returned by `GET /v1/webhooks`. The raw\n`secret` field is intentionally absent — only `secret_prefix`\nis included.\n",
        "properties": {
          "created_at": {
            "example": "2026-05-12T19:00:00Z",
            "format": "date-time",
            "type": "string"
          },
          "id": {
            "example": "8f4c2c4d-8b73-4f71-9e2c-b1ad8f0a9b72",
            "format": "uuid",
            "type": "string"
          },
          "secret_prefix": {
            "description": "First 8 characters of the secret body. Safe to display.",
            "example": "p4ZxQy8k",
            "type": "string"
          },
          "signed": {
            "example": true,
            "type": "boolean"
          },
          "url": {
            "example": "https://example.com/webhooks/fora",
            "format": "uri",
            "type": "string"
          }
        },
        "required": [
          "id",
          "url",
          "signed",
          "secret_prefix",
          "created_at"
        ],
        "type": "object"
      },
      "WebhookCreated": {
        "description": "Response from `POST /v1/webhooks`. The `secret` field is the raw\nsigning secret and is returned EXACTLY ONCE — store it\nimmediately.\n",
        "properties": {
          "created_at": {
            "example": "2026-05-12T19:00:00Z",
            "format": "date-time",
            "type": "string"
          },
          "id": {
            "description": "The webhook id, used in `DELETE /v1/webhooks/{id}`.",
            "example": "8f4c2c4d-8b73-4f71-9e2c-b1ad8f0a9b72",
            "format": "uuid",
            "type": "string"
          },
          "secret": {
            "description": "Raw signing secret, prefixed `whsec_`. **Returned exactly\nonce.** No subsequent endpoint exposes the plaintext —\nstore it in your secrets manager immediately.\n",
            "example": "whsec_p4ZxQy8k3J9vR2nLmWd1cFxV6tHs0aB7",
            "type": "string"
          },
          "secret_prefix": {
            "description": "First 8 characters of the secret body (after the `whsec_`\nliteral). Safe to display — used to identify the webhook\nin a UI without leaking the secret.\n",
            "example": "p4ZxQy8k",
            "type": "string"
          },
          "signed": {
            "description": "Whether outbound deliveries carry the HMAC signature header.",
            "example": true,
            "type": "boolean"
          },
          "url": {
            "example": "https://example.com/webhooks/fora",
            "format": "uri",
            "type": "string"
          }
        },
        "required": [
          "id",
          "url",
          "signed",
          "secret",
          "secret_prefix",
          "created_at"
        ],
        "type": "object"
      },
      "WebhookDeliveriesList": {
        "description": "Cursor-paginated response from `GET /v1/webhook-deliveries`.\nThe `next_cursor` field is **omitted (not null)** when\n`has_more` is `false` — callers should branch on key presence,\nnot on a null value. Stripe-style.\n",
        "properties": {
          "data": {
            "description": "Deliveries on this page, newest first.",
            "items": {
              "$ref": "#/components/schemas/WebhookDelivery"
            },
            "type": "array"
          },
          "has_more": {
            "description": "True if at least one row exists beyond this page.",
            "example": true,
            "type": "boolean"
          },
          "next_cursor": {
            "description": "Opaque pagination token. Pass back as `?cursor=...` to\nfetch the next page. **Omitted** when `has_more` is\n`false`.\n",
            "example": "eyJjIjoiMjAyNi0wNS0xMlQxOTowMDowMFoiLCJpIjoiZjFlMmQzYzQtYjVhNi00OTc4LTk2ODUtYWJjZGVmMDEyMzQ1In0",
            "type": "string"
          }
        },
        "required": [
          "data",
          "has_more"
        ],
        "type": "object"
      },
      "WebhookDelivery": {
        "description": "One persisted webhook delivery attempt. Returned by\n`GET /v1/webhook-deliveries`. The full request payload is\ntruncated to `body_preview` (first 1000 bytes); a future\nsingle-delivery endpoint will surface the full body when needed.\nThe HMAC signing secret is **never** part of this shape — the\nsignature lives in the outbound HTTP header at delivery time and\nis not stored.\n",
        "properties": {
          "attempt": {
            "description": "Number of HTTP attempts made so far. 1 after the inline\nfirst attempt; bumped on each retry. A redelivery starts at\n0 and is incremented to 1 on its first try.\n",
            "example": 6,
            "minimum": 0,
            "type": "integer"
          },
          "body_preview": {
            "description": "First 1000 bytes of the rendered request payload. If the\nfull payload exceeded 1000 bytes, the suffix `…(truncated)`\nis appended. The signature secret is NEVER in this string —\nthe payload is the JSON job result body, computed once at\nenqueue time, and the signature is in the outbound HTTP\nheader at delivery time.\n",
            "example": "{\"job_id\":\"550e8400-e29b-41d4-a716-446655440000\",\"status\":\"completed\"}",
            "type": "string"
          },
          "created_at": {
            "description": "Same value as `delivered_at` for now (the table only tracks\none timestamp). Populated for forward-compat with a future\nschema split.\n",
            "example": "2026-05-12T19:00:00Z",
            "format": "date-time",
            "type": "string"
          },
          "delivered_at": {
            "description": "Timestamp of the most recent attempt. Renamed from the\nlegacy column name; monotonically advanced by each retry.\n",
            "example": "2026-05-12T19:00:00Z",
            "format": "date-time",
            "type": "string"
          },
          "event_id": {
            "description": "Stable across all retry attempts of a single delivery; new\nfor each fresh job. This is the value the receiver sees in\nthe `Fora-Event-Id` header — use it as your dedupe key.\n",
            "example": "11111111-2222-4333-8444-555555555555",
            "format": "uuid",
            "type": "string"
          },
          "final_status": {
            "$ref": "#/components/schemas/WebhookDeliveryStatus"
          },
          "id": {
            "example": "f1e2d3c4-b5a6-4978-9685-abcdef012345",
            "format": "uuid",
            "type": "string"
          },
          "job_id": {
            "description": "The job whose completion triggered this delivery.",
            "example": "550e8400-e29b-41d4-a716-446655440000",
            "format": "uuid",
            "type": "string"
          },
          "last_error": {
            "description": "Human-readable error from the most recent failed attempt. Null on succeeded rows.",
            "example": "HTTP 500",
            "type": [
              "string",
              "null"
            ]
          },
          "next_attempt_at": {
            "description": "When the worker will fire the next attempt. Null on terminal rows.",
            "example": null,
            "format": "date-time",
            "type": [
              "string",
              "null"
            ]
          },
          "redelivered_from": {
            "description": "For rows minted by the dashboard-only redeliver action\n(`POST /internal/v1/webhook-deliveries/{id}/redeliver`),\nthe id of the original delivery this row replaces. Null on\nnon-redelivery rows.\n",
            "example": null,
            "format": "uuid",
            "type": [
              "string",
              "null"
            ]
          },
          "response_code": {
            "description": "HTTP response code from the most recent attempt. Null if no attempt has yet returned a response.",
            "example": 500,
            "type": [
              "integer",
              "null"
            ]
          },
          "webhook_id": {
            "description": "The webhook this delivery targeted. Null for legacy rows\nthat pre-date the EPIC-005 webhooks resource and were never\nassociated with a registered webhook.\n",
            "example": "8f4c2c4d-8b73-4f71-9e2c-b1ad8f0a9b72",
            "format": "uuid",
            "type": [
              "string",
              "null"
            ]
          }
        },
        "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"
        ],
        "type": "object"
      },
      "WebhookDeliveryRedelivered": {
        "description": "Response from\n`POST /internal/v1/webhook-deliveries/{id}/redeliver`. The new\ndelivery row is enqueued with `final_status: pending`; the\nretry worker picks it up on its next poll cycle (max ~30s\nlatency).\n",
        "properties": {
          "event_id": {
            "description": "Fresh `Fora-Event-Id` for the redelivery. Distinct from the\noriginal — the receiver's dedupe MUST treat this as a new\nevent.\n",
            "example": "22222222-3333-4444-8555-666666666666",
            "format": "uuid",
            "type": "string"
          },
          "id": {
            "description": "Id of the new delivery row.",
            "example": "9a8b7c6d-5e4f-4321-8765-fedcba987654",
            "format": "uuid",
            "type": "string"
          },
          "redelivered_from": {
            "description": "Id of the original delivery being replayed.",
            "example": "f1e2d3c4-b5a6-4978-9685-abcdef012345",
            "format": "uuid",
            "type": "string"
          },
          "status": {
            "description": "Always `pending` — the worker will move the row to a terminal status on its next claim.",
            "enum": [
              "pending"
            ],
            "type": "string"
          }
        },
        "required": [
          "id",
          "event_id",
          "redelivered_from",
          "status"
        ],
        "type": "object"
      },
      "WebhookDeliveryStatus": {
        "description": "Terminal status of a single webhook delivery row. `pending` rows\nare still moving through the retry curve (TICKET-018);\n`succeeded` and `permanently_failed` are terminal.\n",
        "enum": [
          "pending",
          "succeeded",
          "permanently_failed"
        ],
        "example": "succeeded",
        "type": "string"
      },
      "WebhookList": {
        "description": "Response from `GET /v1/webhooks`.",
        "properties": {
          "data": {
            "description": "Non-revoked webhooks for the calling account, newest first.",
            "items": {
              "$ref": "#/components/schemas/Webhook"
            },
            "type": "array"
          }
        },
        "required": [
          "data"
        ],
        "type": "object"
      }
    },
    "securitySchemes": {
      "bearerAuth": {
        "bearerFormat": "fora_live_...",
        "description": "Pass the raw API key as `Authorization: Bearer fora_live_...`. Keys\nare issued by `POST /v1/accounts` or `POST /v1/quickstart`. Rotate\nvia the dashboard.\n",
        "scheme": "bearer",
        "type": "http"
      },
      "internalSecret": {
        "description": "Dashboard-only shared secret. Endpoints under `/internal/v1/`\nauthenticate via this header instead of the customer API key —\nthe dashboard ships the secret in its environment so the user\nnever sees it. Customer-facing code MUST NOT call these\nendpoints; per the lane policy, the customer API key is not a\nvalid credential here and will be rejected with `401`.\n",
        "in": "header",
        "name": "X-Fora-Internal-Secret",
        "type": "apiKey"
      }
    }
  },
  "info": {
    "contact": {
      "email": "support@getfora.ai",
      "name": "Fora support",
      "url": "https://getfora.ai"
    },
    "description": "The Fora API translates strings and images into a fixed set of locales and\ndelivers the results either via polling (`GET /v1/content/{id}`) or via\nwebhook callback. Authentication uses a single bearer token per account\n(`Authorization: Bearer fora_live_...`).\n\nThis spec documents the customer-facing surface only. Internal\ndashboard-only endpoints (mounted under `/internal/v1/`) and Stripe\nwebhook routes are intentionally omitted — they are not part of the\ncustomer contract.\n\nThe canonical source is `openapi/openapi.yaml` in the\n[getfora/fora](https://github.com/getfora/fora) repository. The same\ndocument is served live at `GET /v1/openapi.json` (JSON) and\n`GET /v1/openapi.yaml` (YAML) for tooling.\n",
    "license": {
      "name": "Proprietary",
      "url": "https://getfora.ai/terms"
    },
    "summary": "i18n infrastructure — translate Postgres content (text + images) on demand.",
    "title": "Fora API",
    "version": "0.5.0"
  },
  "openapi": "3.1.0",
  "paths": {
    "/internal/v1/webhook-deliveries/{id}/redeliver": {
      "post": {
        "description": "Mints a new `webhook_deliveries` row that copies the original\ndelivery's payload and headers but assigns a fresh `event_id`\nand links back via `redelivered_from`. The retry worker\n(TICKET-018) picks the new row up on its next poll cycle (max\n~30 second latency).\n\n**Dashboard-only.** Authenticates via the\n`X-Fora-Internal-Secret` header — customer API keys are NOT\naccepted on this path. See the Webhooks tag description for\nwhy customer-callable redelivery is intentionally not shipped.\n\nCross-account opacity: a delivery owned by an account other than\nthe one identified by `?email=` returns `404`, never `403`. The\nsame code is returned when the email matches no account at all.\n",
        "operationId": "redeliverWebhookDelivery",
        "parameters": [
          {
            "description": "The delivery UUID returned from `GET /v1/webhook-deliveries`.",
            "in": "path",
            "name": "id",
            "required": true,
            "schema": {
              "example": "f1e2d3c4-b5a6-4978-9685-abcdef012345",
              "format": "uuid",
              "type": "string"
            }
          },
          {
            "description": "Account email. The operator must specify which account owns\nthe delivery so the cross-account check resolves to a 404\ninstead of silently succeeding against the wrong tenant.\n",
            "example": "ops@example.com",
            "in": "query",
            "name": "email",
            "required": true,
            "schema": {
              "format": "email",
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "examples": {
                  "ok": {
                    "value": {
                      "event_id": "22222222-3333-4444-8555-666666666666",
                      "id": "9a8b7c6d-5e4f-4321-8765-fedcba987654",
                      "redelivered_from": "f1e2d3c4-b5a6-4978-9685-abcdef012345",
                      "status": "pending"
                    }
                  }
                },
                "schema": {
                  "$ref": "#/components/schemas/WebhookDeliveryRedelivered"
                }
              }
            },
            "description": "Delivery enqueued for redelivery. Worker picks up within ~30s."
          },
          "400": {
            "content": {
              "application/json": {
                "examples": {
                  "invalid_delivery_id": {
                    "value": {
                      "error": "invalid delivery id",
                      "error_details": {
                        "code": "invalid_delivery_id",
                        "param": "id",
                        "request_id": "req_01HX9PT2W4Y6NJZAB3CDEF0211",
                        "type": "invalid_request_error"
                      }
                    }
                  },
                  "missing_email": {
                    "value": {
                      "error": "email query parameter is required",
                      "error_details": {
                        "code": "missing_account_email",
                        "param": "email",
                        "request_id": "req_01HX9PT2W4Y6NJZAB3CDEF0210",
                        "type": "invalid_request_error"
                      }
                    }
                  }
                },
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Missing or malformed `email` query param, or malformed delivery id."
          },
          "401": {
            "content": {
              "application/json": {
                "examples": {
                  "unauthorized": {
                    "value": {
                      "error": "unauthorized",
                      "error_details": {
                        "code": "unauthorized",
                        "request_id": "req_01HX9PT2W4Y6NJZAB3CDEF0212",
                        "type": "authentication_error"
                      }
                    }
                  }
                },
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Missing or invalid internal secret."
          },
          "404": {
            "content": {
              "application/json": {
                "examples": {
                  "not_found": {
                    "value": {
                      "error": "delivery not found",
                      "error_details": {
                        "code": "delivery_not_found",
                        "request_id": "req_01HX9PT2W4Y6NJZAB3CDEF0213",
                        "type": "not_found_error"
                      }
                    }
                  }
                },
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Delivery not found, or owned by an account other than the\nemail's, or the email matches no account. Per the\ncross-account opacity rule, all three cases share the same\nresponse.\n"
          },
          "409": {
            "content": {
              "application/json": {
                "examples": {
                  "in_progress": {
                    "value": {
                      "error": "delivery is still in retry; cannot redeliver until it reaches a terminal status",
                      "error_details": {
                        "code": "delivery_in_progress",
                        "request_id": "req_01HX9PT2W4Y6NJZAB3CDEF0214",
                        "type": "invalid_request_error"
                      }
                    }
                  }
                },
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Delivery is currently `pending` — the worker hasn't given up\non it yet. Wait for it to reach a terminal status before\nissuing a manual redelivery.\n"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        },
        "security": [
          {
            "internalSecret": []
          }
        ],
        "summary": "Manually redeliver a webhook (dashboard-only)",
        "tags": [
          "Webhooks"
        ]
      }
    },
    "/v1/accounts": {
      "post": {
        "description": "Creates an account from an email address and returns the raw API key.\nThe key is shown exactly once — store it somewhere safe. If SES is\nconfigured server-side, a copy of the key is also emailed to the\nprovided address.\n\nRate-limited to 5 signups per hour per source IP.\n",
        "operationId": "createAccount",
        "requestBody": {
          "content": {
            "application/json": {
              "examples": {
                "signup": {
                  "value": {
                    "email": "engineer@example.com"
                  }
                }
              },
              "schema": {
                "$ref": "#/components/schemas/CreateAccountRequest"
              }
            }
          },
          "required": true
        },
        "responses": {
          "201": {
            "content": {
              "application/json": {
                "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."
                    }
                  }
                },
                "schema": {
                  "$ref": "#/components/schemas/CreateAccountResponse"
                }
              }
            },
            "description": "Account created."
          },
          "400": {
            "content": {
              "application/json": {
                "examples": {
                  "missing_email": {
                    "value": {
                      "error": "email is required",
                      "error_details": {
                        "code": "missing_email",
                        "param": "email",
                        "request_id": "req_01HX9PT2W4Y6NJZAB3CDEF0020",
                        "type": "invalid_request_error"
                      }
                    }
                  }
                },
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Missing or malformed email."
          },
          "409": {
            "content": {
              "application/json": {
                "examples": {
                  "duplicate": {
                    "value": {
                      "error": "email already registered",
                      "error_details": {
                        "code": "email_already_exists",
                        "param": "email",
                        "request_id": "req_01HX9PT2W4Y6NJZAB3CDEF0021",
                        "type": "invalid_request_error"
                      }
                    }
                  }
                },
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "An account already exists for this email."
          },
          "429": {
            "content": {
              "application/json": {
                "examples": {
                  "rate_limited": {
                    "value": {
                      "error": "too many signups from this address, try again later",
                      "error_details": {
                        "code": "rate_limit_signup",
                        "request_id": "req_01HX9PT2W4Y6NJZAB3CDEF0022",
                        "type": "rate_limit_error"
                      }
                    }
                  }
                },
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Too many signup attempts from this IP."
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        },
        "security": [],
        "summary": "Create a new account",
        "tags": [
          "Accounts"
        ]
      }
    },
    "/v1/accounts/me/connector/heartbeat": {
      "post": {
        "description": "Called by the customer-deployed Postgres connector once per polling\ncycle (default every 30 seconds). The heartbeat reports the\nconnector's name plus simple sync metrics; the server upserts the\nlatest record per `(account_id, connector_name)`. Used to power the\ndashboard's connector status pane and to detect stale connectors.\n\nCustomers do not normally call this endpoint directly — it is part of\nthe connector binary's contract. See\n[docs/connector](https://getfora.ai/docs/connector).\n",
        "operationId": "connectorHeartbeat",
        "requestBody": {
          "content": {
            "application/json": {
              "examples": {
                "normal": {
                  "value": {
                    "backlog": 0,
                    "connector_name": "products-prod",
                    "rows_failed": 0,
                    "rows_synced": 1284
                  }
                },
                "with_failures": {
                  "value": {
                    "backlog": 17,
                    "connector_name": "blog-staging",
                    "rows_failed": 3,
                    "rows_synced": 42
                  }
                }
              },
              "schema": {
                "$ref": "#/components/schemas/HeartbeatRequest"
              }
            }
          },
          "required": true
        },
        "responses": {
          "204": {
            "description": "Heartbeat recorded."
          },
          "400": {
            "content": {
              "application/json": {
                "examples": {
                  "missing_name": {
                    "value": {
                      "error": "connector_name is required",
                      "error_details": {
                        "code": "missing_connector_name",
                        "param": "connector_name",
                        "request_id": "req_01HX9PT2W4Y6NJZAB3CDEF0050",
                        "type": "invalid_request_error"
                      }
                    }
                  }
                },
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Missing connector_name or malformed body."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "summary": "Submit a connector heartbeat",
        "tags": [
          "Connector"
        ]
      }
    },
    "/v1/accounts/storage": {
      "post": {
        "description": "Saves a customer-owned S3 bucket + AWS credentials so that translated\nimages can be written directly into the customer's bucket via the\n`image_destination_url` field on `POST /v1/translate`. Credentials\nare KMS-encrypted at rest.\n\nThe IAM user / role behind the supplied credentials needs only\n`s3:PutObject` on the destination bucket.\n",
        "operationId": "setStorageConfig",
        "requestBody": {
          "content": {
            "application/json": {
              "examples": {
                "s3": {
                  "value": {
                    "access_key_id": "AKIAIOSFODNN7EXAMPLE",
                    "bucket_name": "my-bucket",
                    "region": "us-east-1",
                    "secret_access_key": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
                  }
                }
              },
              "schema": {
                "$ref": "#/components/schemas/StorageConfigRequest"
              }
            }
          },
          "required": true
        },
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "examples": {
                  "ok": {
                    "value": {
                      "status": "ok"
                    }
                  }
                },
                "schema": {
                  "$ref": "#/components/schemas/StatusOK"
                }
              }
            },
            "description": "Storage configuration saved."
          },
          "400": {
            "content": {
              "application/json": {
                "examples": {
                  "missing_fields": {
                    "value": {
                      "error": "bucket_name, region, access_key_id, and secret_access_key are required",
                      "error_details": {
                        "code": "missing_storage_fields",
                        "request_id": "req_01HX9PT2W4Y6NJZAB3CDEF0040",
                        "type": "invalid_request_error"
                      }
                    }
                  }
                },
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Missing or malformed storage fields."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "summary": "Configure customer-owned S3 storage for image translations",
        "tags": [
          "Storage"
        ]
      }
    },
    "/v1/connectors": {
      "get": {
        "description": "Returns every connector that has reported a heartbeat for the\ncalling account, with the last-known sync metrics per\nconnector. Mirrors the `connectors` array embedded in the\ndashboard-only stats payload — the API equivalent of the\ndashboard's connector status pane.\n\nAn account with zero connectors returns `{\"data\": []}` (200,\nnot 404). Cross-account isolation is enforced in SQL.\n\nNo pagination — accounts have a handful of connectors per\nenvironment, not pages of them. Cursor pagination is deferred\nuntil volume warrants it.\n",
        "operationId": "listConnectors",
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "examples": {
                  "empty": {
                    "value": {
                      "data": []
                    }
                  },
                  "two": {
                    "value": {
                      "data": [
                        {
                          "backlog": 0,
                          "connector_name": "products-prod",
                          "rows_failed": 0,
                          "rows_synced": 1284,
                          "synced_at": "2026-05-12T19:00:00Z"
                        },
                        {
                          "backlog": 17,
                          "connector_name": "blog-staging",
                          "rows_failed": 3,
                          "rows_synced": 42,
                          "synced_at": "2026-05-12T18:42:00Z"
                        }
                      ]
                    }
                  }
                },
                "schema": {
                  "$ref": "#/components/schemas/ConnectorList"
                }
              }
            },
            "description": "Connectors for the calling account, newest heartbeat first."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "summary": "List the calling account's connectors",
        "tags": [
          "Connector"
        ]
      }
    },
    "/v1/content/{id}": {
      "get": {
        "description": "Returns the translated text and/or image URLs for a given job and\nlocale. While the job is still pending or running, the response is\n`202` with the current status (and optional `error` if the job has\nfailed). Once the job is complete, the response is `200` with the\ntranslated content.\n\nThe `locale` query parameter is required and must be one of the\nsupported locales (`de`, `es`, `fr`, `it`, `pt-BR`).\n",
        "operationId": "getJobContent",
        "parameters": [
          {
            "description": "The job UUID returned from `POST /v1/translate`.",
            "in": "path",
            "name": "id",
            "required": true,
            "schema": {
              "example": "550e8400-e29b-41d4-a716-446655440000",
              "format": "uuid",
              "type": "string"
            }
          },
          {
            "description": "Target locale to read.",
            "in": "query",
            "name": "locale",
            "required": true,
            "schema": {
              "$ref": "#/components/schemas/Locale"
            }
          }
        ],
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "examples": {
                  "image_complete": {
                    "summary": "Completed image translation",
                    "value": {
                      "job_id": "550e8400-e29b-41d4-a716-446655440000",
                      "locale": "fr",
                      "status": "completed",
                      "translated_image_urls": [
                        "https://my-bucket.s3.us-east-1.amazonaws.com/banners/welcome-fr.jpg"
                      ],
                      "translated_text": ""
                    }
                  },
                  "text_complete": {
                    "summary": "Completed text translation",
                    "value": {
                      "job_id": "550e8400-e29b-41d4-a716-446655440000",
                      "locale": "es",
                      "status": "completed",
                      "translated_image_urls": [],
                      "translated_text": "Bienvenido a nuestro mercado. Compra y vende con confianza."
                    }
                  }
                },
                "schema": {
                  "$ref": "#/components/schemas/Content"
                }
              }
            },
            "description": "Job is complete; translated content is returned."
          },
          "202": {
            "content": {
              "application/json": {
                "examples": {
                  "failed": {
                    "value": {
                      "error": "upstream model timeout after 3 retries",
                      "job_id": "550e8400-e29b-41d4-a716-446655440000",
                      "status": "failed"
                    }
                  },
                  "pending": {
                    "value": {
                      "error": "",
                      "job_id": "550e8400-e29b-41d4-a716-446655440000",
                      "status": "pending"
                    }
                  }
                },
                "schema": {
                  "$ref": "#/components/schemas/JobInProgress"
                }
              }
            },
            "description": "Job is still pending or has failed; check `status`."
          },
          "400": {
            "content": {
              "application/json": {
                "examples": {
                  "missing_locale": {
                    "value": {
                      "error": "locale query parameter is required",
                      "error_details": {
                        "code": "missing_locale",
                        "param": "locale",
                        "request_id": "req_01HX9PT2W4Y6NJZAB3CDEF0010",
                        "type": "invalid_request_error"
                      }
                    }
                  }
                },
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Missing or unsupported locale, or malformed job id."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "content": {
              "application/json": {
                "examples": {
                  "job_not_found": {
                    "value": {
                      "error": "job not found",
                      "error_details": {
                        "code": "job_not_found",
                        "request_id": "req_01HX9PT2W4Y6NJZAB3CDEF0011",
                        "type": "not_found_error"
                      }
                    }
                  }
                },
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Job not found, or no translation exists for the requested locale."
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "summary": "Fetch translated content for a job",
        "tags": [
          "Translate"
        ]
      }
    },
    "/v1/jobs": {
      "get": {
        "description": "Returns the calling account's jobs in newest-first order with\ncursor pagination. The response envelope is `{data, has_more,\nnext_cursor?}` — the `next_cursor` field is **omitted (not\nnull)** when `has_more` is `false`, matching the Stripe-style\nwire posture used elsewhere in the API.\n\nPass the value of `next_cursor` back as the `cursor` query\nparameter to fetch the next page. The cursor is opaque\n(URL-safe base64 of a small JSON object encoding the\n`(created_at, id)` tuple of the last row on the current page) —\ndo not parse it on the client side; treat it as a token.\n\nCross-account isolation is enforced server-side; you cannot\nread another account's jobs by guessing a cursor or job id.\n",
        "operationId": "listJobs",
        "parameters": [
          {
            "description": "Page size. Default 25. Values above 100 are clamped to 100;\nvalues \u003c= 0 or non-integers return `400 invalid_limit`.\n",
            "example": 25,
            "in": "query",
            "name": "limit",
            "required": false,
            "schema": {
              "default": 25,
              "maximum": 100,
              "minimum": 1,
              "type": "integer"
            }
          },
          {
            "description": "Opaque pagination token returned in the previous page's\n`next_cursor`. Malformed cursors return `400 invalid_cursor`.\n",
            "example": "eyJjIjoiMjAyNi0wNS0xMlQxOTowMDowMFoiLCJpIjoiOGY0YzJjNGQtOGI3My00ZjcxLTllMmMtYjFhZDhmMGE5YjcyIn0",
            "in": "query",
            "name": "cursor",
            "required": false,
            "schema": {
              "type": "string"
            }
          },
          {
            "description": "Filter by lifecycle status. Invalid values return\n`400 invalid_status`.\n",
            "example": "failed",
            "in": "query",
            "name": "status",
            "required": false,
            "schema": {
              "$ref": "#/components/schemas/JobStatus"
            }
          },
          {
            "description": "Return only jobs with `created_at \u003e= since`. RFC3339\ntimestamp; invalid values return `400 invalid_since`.\n",
            "example": "2026-05-01T00:00:00Z",
            "in": "query",
            "name": "since",
            "required": false,
            "schema": {
              "format": "date-time",
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "examples": {
                  "first_page": {
                    "summary": "First page, more rows available",
                    "value": {
                      "data": [
                        {
                          "completed_at": "2026-05-12T19:00:04Z",
                          "created_at": "2026-05-12T19:00:00Z",
                          "error": null,
                          "has_images": false,
                          "has_text": true,
                          "id": "8f4c2c4d-8b73-4f71-9e2c-b1ad8f0a9b72",
                          "quality_score": 0.92,
                          "source_text": "Welcome to our marketplace.",
                          "status": "completed",
                          "target_locales": [
                            "es",
                            "fr"
                          ]
                        }
                      ],
                      "has_more": true,
                      "next_cursor": "eyJjIjoiMjAyNi0wNS0xMlQxOTowMDowMFoiLCJpIjoiOGY0YzJjNGQtOGI3My00ZjcxLTllMmMtYjFhZDhmMGE5YjcyIn0"
                    }
                  },
                  "last_page": {
                    "summary": "Final page, next_cursor absent",
                    "value": {
                      "data": [
                        {
                          "completed_at": "2026-05-10T08:30:02Z",
                          "created_at": "2026-05-10T08:30:00Z",
                          "error": "translation provider timeout",
                          "has_images": false,
                          "has_text": true,
                          "id": "1a2b3c4d-5e6f-4a8b-9c0d-1e2f3a4b5c6d",
                          "quality_score": null,
                          "source_text": "Hello world",
                          "status": "failed",
                          "target_locales": [
                            "de"
                          ]
                        }
                      ],
                      "has_more": false
                    }
                  }
                },
                "schema": {
                  "$ref": "#/components/schemas/JobsList"
                }
              }
            },
            "description": "A page of jobs. `next_cursor` is omitted when `has_more` is `false`."
          },
          "400": {
            "content": {
              "application/json": {
                "examples": {
                  "invalid_cursor": {
                    "value": {
                      "error": "cursor is not a valid pagination cursor",
                      "error_details": {
                        "code": "invalid_cursor",
                        "param": "cursor",
                        "request_id": "req_01HX9PT2W4Y6NJZAB3CDEF0091",
                        "type": "invalid_request_error"
                      }
                    }
                  },
                  "invalid_limit": {
                    "value": {
                      "error": "limit must be a positive integer",
                      "error_details": {
                        "code": "invalid_limit",
                        "param": "limit",
                        "request_id": "req_01HX9PT2W4Y6NJZAB3CDEF0090",
                        "type": "invalid_request_error"
                      }
                    }
                  },
                  "invalid_since": {
                    "value": {
                      "error": "since must be an RFC3339 timestamp (e.g. 2026-05-01T00:00:00Z)",
                      "error_details": {
                        "code": "invalid_since",
                        "param": "since",
                        "request_id": "req_01HX9PT2W4Y6NJZAB3CDEF0093",
                        "type": "invalid_request_error"
                      }
                    }
                  },
                  "invalid_status": {
                    "value": {
                      "error": "status must be one of pending, processing, completed, failed",
                      "error_details": {
                        "code": "invalid_status",
                        "param": "status",
                        "request_id": "req_01HX9PT2W4Y6NJZAB3CDEF0092",
                        "type": "invalid_request_error"
                      }
                    }
                  }
                },
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Malformed query parameter."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "summary": "List the calling account's translation jobs",
        "tags": [
          "Jobs"
        ]
      }
    },
    "/v1/jobs/{id}": {
      "get": {
        "description": "Returns the full job record for a single translation job owned by\nthe calling account. Per-locale translation results are inlined\nunder the `translations` key (Stripe-style \"expanded resource\") so\na polling client can read everything about a job in one round\ntrip — no need to issue one `GET /v1/content/{id}?locale=...`\nrequest per target locale.\n\nCross-account isolation is enforced server-side: a job owned by\nanother account returns `404 job_not_found` (NOT `403`) so the\nendpoint never leaks the existence of an id you do not own.\n\nSnake_case end-to-end. The wire shape is pinned by\n`TestJobDetail_StoreTypesSerializeAsSnakeCase` in\n`internal/api/jobs_get_test.go`.\n",
        "operationId": "getJob",
        "parameters": [
          {
            "description": "The job UUID returned from `POST /v1/translate`.",
            "in": "path",
            "name": "id",
            "required": true,
            "schema": {
              "example": "8f4c2c4d-8b73-4f71-9e2c-b1ad8f0a9b72",
              "format": "uuid",
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "examples": {
                  "completed_text": {
                    "summary": "Completed text job, two locales",
                    "value": {
                      "completed_at": "2026-05-12T19:00:04Z",
                      "created_at": "2026-05-12T19:00:00Z",
                      "custom_instructions": "Use a friendly, marketing tone.",
                      "error": null,
                      "id": "8f4c2c4d-8b73-4f71-9e2c-b1ad8f0a9b72",
                      "image_destination_url": null,
                      "protected_terms": [
                        "Acme"
                      ],
                      "source_image_urls": [],
                      "source_text": "Welcome to our marketplace.",
                      "status": "completed",
                      "target_locales": [
                        "es",
                        "fr"
                      ],
                      "translations": [
                        {
                          "locale": "es",
                          "quality_score": null,
                          "translated_image_urls": [],
                          "translated_text": "Bienvenido a nuestro mercado."
                        },
                        {
                          "locale": "fr",
                          "quality_score": null,
                          "translated_image_urls": [],
                          "translated_text": "Bienvenue sur notre marché."
                        }
                      ],
                      "webhook_url": "https://example.com/webhooks/fora"
                    }
                  },
                  "in_flight": {
                    "summary": "Job is still pending; translations slice is empty",
                    "value": {
                      "completed_at": null,
                      "created_at": "2026-05-12T19:00:00Z",
                      "custom_instructions": null,
                      "error": null,
                      "id": "1a2b3c4d-5e6f-4a8b-9c0d-1e2f3a4b5c6d",
                      "image_destination_url": null,
                      "protected_terms": [],
                      "source_image_urls": [],
                      "source_text": "Hello world",
                      "status": "pending",
                      "target_locales": [
                        "de"
                      ],
                      "translations": [],
                      "webhook_url": null
                    }
                  }
                },
                "schema": {
                  "$ref": "#/components/schemas/JobDetail"
                }
              }
            },
            "description": "Job record with per-locale results inlined."
          },
          "400": {
            "content": {
              "application/json": {
                "examples": {
                  "invalid_job_id": {
                    "value": {
                      "error": "invalid job id",
                      "error_details": {
                        "code": "invalid_job_id",
                        "param": "id",
                        "request_id": "req_01HX9PT2W4Y6NJZAB3CDEF0100",
                        "type": "invalid_request_error"
                      }
                    }
                  }
                },
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Malformed job id."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "content": {
              "application/json": {
                "examples": {
                  "not_found": {
                    "value": {
                      "error": "job not found",
                      "error_details": {
                        "code": "job_not_found",
                        "request_id": "req_01HX9PT2W4Y6NJZAB3CDEF0101",
                        "type": "not_found_error"
                      }
                    }
                  }
                },
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Job not found, or owned by another account."
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "summary": "Fetch a single translation job (with per-locale translations)",
        "tags": [
          "Jobs"
        ]
      }
    },
    "/v1/jobs/{id}/retry": {
      "post": {
        "description": "Reads the original job's `source_text`, `source_image_urls`,\n`target_locales`, `webhook_url`, `image_destination_url`,\n`protected_terms`, and `custom_instructions`, and submits them as\na brand-new job (new `job_id`, `status: pending`). The response\nshape mirrors `POST /v1/translate` so retry callers and fresh\ncallers can share serialization code.\n\nA retry counts as a new job for usage and is quota-checked the\nsame way as a first-time submission.\n\nStatus rules:\n\n  - The original job must be in a terminal state (`completed` or\n    `failed`). Retrying a `pending` or `processing` job returns\n    `409 job_in_progress`. Customers who need at-most-once\n    submission semantics across a network blip should send the\n    same `Idempotency-Key` header — the wrapping middleware will\n    replay the cached response instead of producing a second\n    job.\n  - Cross-account or missing source jobs return `404\n    job_not_found` (NOT `403`); the endpoint does not leak the\n    existence of jobs you do not own.\n\nThe retry endpoint honors the same `Idempotency-Key` middleware\nas `POST /v1/translate` (TICKET-024). Same key + same source\njob within 24h returns the cached `202` with\n`Idempotent-Replayed: true` set in the response headers.\n",
        "operationId": "retryJob",
        "parameters": [
          {
            "description": "The source job UUID to retry.",
            "in": "path",
            "name": "id",
            "required": true,
            "schema": {
              "example": "8f4c2c4d-8b73-4f71-9e2c-b1ad8f0a9b72",
              "format": "uuid",
              "type": "string"
            }
          },
          {
            "description": "Optional Stripe-style idempotency token. When set, the same\nkey replayed within 24 hours against the same source job\nreturns the cached response with `Idempotent-Replayed: true`\nset in the response headers. Same key with a different\nsource-job url (different request body, since chi includes\nthe path in the body hash via the request line) returns\n`409 idempotency_key_in_use`. Length 1..255 ASCII printable\ncharacters; otherwise `400 invalid_idempotency_key`. See\n`POST /v1/translate` for the full contract.\n",
            "example": "retry-550e8400-e29b-41d4-a716-446655440000",
            "in": "header",
            "name": "Idempotency-Key",
            "required": false,
            "schema": {
              "maxLength": 255,
              "minLength": 1,
              "pattern": "^[\\x20-\\x7E]+$",
              "type": "string"
            }
          }
        ],
        "responses": {
          "202": {
            "content": {
              "application/json": {
                "examples": {
                  "accepted": {
                    "value": {
                      "job_id": "1a2b3c4d-5e6f-4a8b-9c0d-1e2f3a4b5c6d",
                      "status": "pending"
                    }
                  }
                },
                "schema": {
                  "$ref": "#/components/schemas/JobAccepted"
                }
              }
            },
            "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\nthe Idempotency-Key cache rather than executed fresh.\nAbsent on a fresh request. See TICKET-024.\n",
                "schema": {
                  "enum": [
                    "true"
                  ],
                  "type": "string"
                }
              }
            }
          },
          "400": {
            "content": {
              "application/json": {
                "examples": {
                  "invalid_idempotency_key": {
                    "value": {
                      "error": "Idempotency-Key must be 1..255 printable ASCII characters",
                      "error_details": {
                        "code": "invalid_idempotency_key",
                        "param": "Idempotency-Key",
                        "request_id": "req_01HX9PT2W4Y6NJZAB3CDEF0111",
                        "type": "invalid_request_error"
                      }
                    }
                  },
                  "invalid_job_id": {
                    "value": {
                      "error": "invalid job id",
                      "error_details": {
                        "code": "invalid_job_id",
                        "param": "id",
                        "request_id": "req_01HX9PT2W4Y6NJZAB3CDEF0110",
                        "type": "invalid_request_error"
                      }
                    }
                  }
                },
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Malformed source-job id or malformed Idempotency-Key."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "402": {
            "content": {
              "application/json": {
                "examples": {
                  "quota": {
                    "value": {
                      "error": "monthly character quota exceeded",
                      "error_details": {
                        "code": "quota_exceeded",
                        "request_id": "req_01HX9PT2W4Y6NJZAB3CDEF0112",
                        "type": "quota_exceeded_error"
                      }
                    }
                  }
                },
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Account has exceeded its plan quota for the month."
          },
          "404": {
            "content": {
              "application/json": {
                "examples": {
                  "not_found": {
                    "value": {
                      "error": "job not found",
                      "error_details": {
                        "code": "job_not_found",
                        "request_id": "req_01HX9PT2W4Y6NJZAB3CDEF0113",
                        "type": "not_found_error"
                      }
                    }
                  }
                },
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Source job not found, or owned by another account."
          },
          "409": {
            "content": {
              "application/json": {
                "examples": {
                  "idempotency_key_in_use": {
                    "value": {
                      "error": "Idempotency-Key has already been used with a different request body",
                      "error_details": {
                        "code": "idempotency_key_in_use",
                        "param": "Idempotency-Key",
                        "request_id": "req_01HX9PT2W4Y6NJZAB3CDEF0115",
                        "type": "invalid_request_error"
                      }
                    }
                  },
                  "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": {
                        "code": "job_in_progress",
                        "request_id": "req_01HX9PT2W4Y6NJZAB3CDEF0114",
                        "type": "invalid_request_error"
                      }
                    }
                  }
                },
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "The retry cannot proceed. Two cases:\n\n- `job_in_progress`: the source job is still `pending` or\n  `processing`. Wait for it to reach a terminal state, or\n  send `Idempotency-Key` to dedupe duplicate retry clicks.\n- `idempotency_key_in_use` / `idempotency_key_in_progress`:\n  same as the `POST /v1/translate` 409 cases; see that\n  endpoint's documentation.\n"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "summary": "Re-submit a previous job's payload as a brand-new job",
        "tags": [
          "Jobs"
        ]
      }
    },
    "/v1/keys": {
      "get": {
        "description": "Returns all non-revoked named keys owned by the calling account.\nOnly `key_prefix` (first 8 chars after `fora_live_`) is included\n— the raw `api_key` is **never** returned by this endpoint.\n\nThe response envelope is `{data: [...]}` (not a bare array) to\nleave room for cursor pagination later. Today's accounts have\nat most a handful of named keys; pagination will be added when\nthe upper end of the distribution justifies it.\n",
        "operationId": "listKeys",
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "examples": {
                  "empty": {
                    "value": {
                      "data": []
                    }
                  },
                  "two": {
                    "value": {
                      "data": [
                        {
                          "created_at": "2026-05-12T19:00:00Z",
                          "id": "8f4c2c4d-8b73-4f71-9e2c-b1ad8f0a9b72",
                          "key_prefix": "fora_live_a1b2c3d4...",
                          "last_used_at": "2026-05-12T20:11:42Z",
                          "name": "ci-prod"
                        },
                        {
                          "created_at": "2026-05-10T11:42:00Z",
                          "id": "1a2b3c4d-5e6f-4a8b-9c0d-1e2f3a4b5c6d",
                          "key_prefix": "fora_live_9f8e7d6c...",
                          "last_used_at": null,
                          "name": "staging"
                        }
                      ]
                    }
                  }
                },
                "schema": {
                  "$ref": "#/components/schemas/KeyList"
                }
              }
            },
            "description": "Named keys for the calling account."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "summary": "List the calling account's named API keys",
        "tags": [
          "Keys"
        ]
      },
      "post": {
        "description": "Creates a new named key for the calling account and returns the\nraw `api_key` in the response. **The raw `api_key` is returned\nEXACTLY ONCE** in this response — store it immediately. No\nsubsequent endpoint exposes the plaintext (`GET /v1/keys`\nreturns only `key_prefix`).\n\n### Idempotency-Key\n\nThis endpoint honors the `Idempotency-Key` header. Replays of a\nsuccessful create within the 24h idempotency window return the\ncached response unchanged — **including the raw `api_key`\nfield**. This is a deliberate exception to the \"raw key returned\nonce\" rule: the cached response IS the original response, so\nreplays will see the same plaintext `api_key`. Customers who\nrequire strict single-emit semantics on the raw key should not\nsend `Idempotency-Key` on this endpoint.\n",
        "operationId": "createKey",
        "parameters": [
          {
            "description": "Optional client-supplied idempotency token. Same key + same\nbody within 24h returns the cached response unchanged\n(including the raw `api_key` plaintext — see description\nabove).\n",
            "in": "header",
            "name": "Idempotency-Key",
            "required": false,
            "schema": {
              "example": "key-create-2026-05-12-001",
              "maxLength": 255,
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "examples": {
                "ci": {
                  "value": {
                    "name": "ci-prod"
                  }
                }
              },
              "schema": {
                "$ref": "#/components/schemas/CreateKeyRequest"
              }
            }
          },
          "required": true
        },
        "responses": {
          "201": {
            "content": {
              "application/json": {
                "examples": {
                  "created": {
                    "value": {
                      "api_key": "fora_live_a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f9",
                      "created_at": "2026-05-12T19:00:00Z",
                      "id": "8f4c2c4d-8b73-4f71-9e2c-b1ad8f0a9b72",
                      "key_prefix": "fora_live_a1b2c3d4...",
                      "name": "ci-prod"
                    }
                  }
                },
                "schema": {
                  "$ref": "#/components/schemas/KeyCreated"
                }
              }
            },
            "description": "Key created. The raw `api_key` field appears here ONCE — store it immediately."
          },
          "400": {
            "content": {
              "application/json": {
                "examples": {
                  "missing_name": {
                    "value": {
                      "error": "name is required",
                      "error_details": {
                        "code": "missing_name",
                        "param": "name",
                        "request_id": "req_01HX9PT2W4Y6NJZAB3CDEF0120",
                        "type": "invalid_request_error"
                      }
                    }
                  }
                },
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Missing or malformed `name`."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "summary": "Create a new named API key",
        "tags": [
          "Keys"
        ]
      }
    },
    "/v1/keys/{id}": {
      "delete": {
        "description": "Marks the named key as revoked (sets `revoked_at = NOW()`).\nSubsequent requests authenticating with the revoked key return\n`401 invalid_api_key`. A key id that does not belong to the\ncalling account returns `404` (cross-account opacity).\n\n**A key cannot revoke itself.** If `{id}` matches the api_keys\nrow id of the key used to authenticate this request, the\nendpoint returns `409 cannot_revoke_self`. Use a different key,\nor rotate via the dashboard.\n",
        "operationId": "deleteKey",
        "parameters": [
          {
            "description": "The api_keys row id returned from `POST /v1/keys`.",
            "in": "path",
            "name": "id",
            "required": true,
            "schema": {
              "example": "8f4c2c4d-8b73-4f71-9e2c-b1ad8f0a9b72",
              "format": "uuid",
              "type": "string"
            }
          }
        ],
        "responses": {
          "204": {
            "description": "Key revoked."
          },
          "400": {
            "content": {
              "application/json": {
                "examples": {
                  "invalid_id": {
                    "value": {
                      "error": "invalid key id",
                      "error_details": {
                        "code": "invalid_key_id",
                        "param": "id",
                        "request_id": "req_01HX9PT2W4Y6NJZAB3CDEF0121",
                        "type": "invalid_request_error"
                      }
                    }
                  }
                },
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Malformed key id."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "content": {
              "application/json": {
                "examples": {
                  "not_found": {
                    "value": {
                      "error": "key not found",
                      "error_details": {
                        "code": "key_not_found",
                        "request_id": "req_01HX9PT2W4Y6NJZAB3CDEF0122",
                        "type": "not_found_error"
                      }
                    }
                  }
                },
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Key not found, or not owned by the calling account."
          },
          "409": {
            "content": {
              "application/json": {
                "examples": {
                  "self": {
                    "value": {
                      "error": "cannot revoke the API key used to authenticate this request; use a different key",
                      "error_details": {
                        "code": "cannot_revoke_self",
                        "param": "id",
                        "request_id": "req_01HX9PT2W4Y6NJZAB3CDEF0123",
                        "type": "invalid_request_error"
                      }
                    }
                  }
                },
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "The `{id}` matches the api_keys row id of the key used to\nauthenticate this request. Use a different key.\n"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "summary": "Revoke a named API key",
        "tags": [
          "Keys"
        ]
      }
    },
    "/v1/openapi.json": {
      "get": {
        "description": "Returns this OpenAPI 3.1 document serialized as JSON. Suitable for\nSDK generators, doc renderers, and contract test tooling. Cached for\n5 minutes.\n",
        "operationId": "getOpenAPIJSON",
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "schema": {
                  "description": "The OpenAPI 3.1 document.",
                  "type": "object"
                }
              }
            },
            "description": "OpenAPI document (JSON)."
          },
          "4XX": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Method not allowed or unknown sub-path."
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        },
        "security": [],
        "summary": "This API contract, as JSON",
        "tags": [
          "Spec"
        ]
      }
    },
    "/v1/openapi.yaml": {
      "get": {
        "description": "Returns this OpenAPI 3.1 document in its canonical YAML form. Cached\nfor 5 minutes.\n",
        "operationId": "getOpenAPIYAML",
        "responses": {
          "200": {
            "content": {
              "application/yaml": {
                "schema": {
                  "description": "The raw YAML source of this document.",
                  "example": "openapi: 3.1.0\ninfo:\n  title: Fora API\n  ...",
                  "type": "string"
                }
              }
            },
            "description": "OpenAPI document (YAML)."
          },
          "4XX": {
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Method not allowed or unknown sub-path."
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        },
        "security": [],
        "summary": "This API contract, as YAML",
        "tags": [
          "Spec"
        ]
      }
    },
    "/v1/quickstart": {
      "post": {
        "description": "Provisions an instant demo account with a synthetic email address and\nreturns the raw API key plus three pre-built `curl` snippets so a\ndeveloper can make a real API call within seconds.\n\nIntended for evaluation only. Demo accounts share the same free-tier\nlimits as full accounts and are subject to periodic cleanup.\nRate-limited to 3 demo accounts per hour per source IP.\n",
        "operationId": "quickstart",
        "responses": {
          "201": {
            "content": {
              "application/json": {
                "examples": {
                  "created": {
                    "value": {
                      "api_key": "fora_live_q9vR2nLmWd1cFxV6tHs0aB7p4ZxQy8k3",
                      "examples": {
                        "get_result": "curl 'https://api.getfora.ai/v1/content/{job_id}?locale=es' \\\n  -H 'Authorization: Bearer fora_live_q9vR2nLmWd1cFxV6tHs0aB7p4ZxQy8k3'",
                        "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\"]}'",
                        "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\"]}'"
                      }
                    }
                  }
                },
                "schema": {
                  "$ref": "#/components/schemas/QuickstartResponse"
                }
              }
            },
            "description": "Demo account created."
          },
          "429": {
            "content": {
              "application/json": {
                "examples": {
                  "rate_limited": {
                    "value": {
                      "error": "too many demo accounts created from this IP; try again later",
                      "error_details": {
                        "code": "rate_limit_quickstart",
                        "request_id": "req_01HX9PT2W4Y6NJZAB3CDEF0030",
                        "type": "rate_limit_error"
                      }
                    }
                  }
                },
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Too many demo accounts created from this IP."
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        },
        "security": [],
        "summary": "Create a throwaway demo account",
        "tags": [
          "Accounts"
        ]
      }
    },
    "/v1/translate": {
      "post": {
        "description": "Creates an asynchronous translation job. Either `source_text` or\n`source_image_urls` (or both) must be provided. The job is queued\nimmediately and the response returns the `job_id` for polling via\n`GET /v1/content/{id}`. If `webhook_url` is provided, Fora will POST\nthe completed job to that URL once translation finishes.\n\nQuota is reserved synchronously before the response is returned. If\nthe account exceeds its monthly free-tier limit, the request fails\nwith `402 quota_exceeded`.\n",
        "operationId": "createTranslateJob",
        "parameters": [
          {
            "description": "Optional Stripe-style idempotency token. When set, the same key\nreplayed within 24 hours with the SAME request body returns the\ncached response with `Idempotent-Replayed: true` set in the\nresponse headers. The same key replayed with a DIFFERENT body\nreturns `409 idempotency_key_in_use`. If a request with the same\nkey is currently in flight (still executing the handler) the\nsibling request returns `409 idempotency_key_in_progress` —\nretry shortly. Scope is per-account. Length 1..255 ASCII\nprintable characters; otherwise the request is rejected with\n`400 invalid_idempotency_key`.\n",
            "example": "550e8400-e29b-41d4-a716-446655440000",
            "in": "header",
            "name": "Idempotency-Key",
            "required": false,
            "schema": {
              "maxLength": 255,
              "minLength": 1,
              "pattern": "^[\\x20-\\x7E]+$",
              "type": "string"
            }
          }
        ],
        "requestBody": {
          "content": {
            "application/json": {
              "examples": {
                "image": {
                  "summary": "Translate one image into French and write to customer S3",
                  "value": {
                    "image_destination_url": "https://my-bucket.s3.us-east-1.amazonaws.com/banners/welcome-fr.jpg",
                    "source_image_urls": [
                      "https://cdn.example.com/banners/welcome-en.jpg"
                    ],
                    "target_locales": [
                      "fr"
                    ]
                  }
                },
                "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": {
                    "custom_instructions": "Use a friendly, marketing tone.",
                    "protected_terms": [
                      "Acme",
                      "Acme Pro"
                    ],
                    "source_text": "Acme Pro subscribers get unlimited storage.",
                    "target_locales": [
                      "es",
                      "fr",
                      "de"
                    ],
                    "webhook_url": "https://example.com/webhooks/fora"
                  }
                }
              },
              "schema": {
                "$ref": "#/components/schemas/TranslateRequest"
              }
            }
          },
          "required": true
        },
        "responses": {
          "202": {
            "content": {
              "application/json": {
                "examples": {
                  "accepted": {
                    "value": {
                      "job_id": "550e8400-e29b-41d4-a716-446655440000",
                      "status": "pending"
                    }
                  }
                },
                "schema": {
                  "$ref": "#/components/schemas/JobAccepted"
                }
              }
            },
            "description": "Job accepted and queued for translation.",
            "headers": {
              "Idempotent-Replayed": {
                "description": "Present and set to `true` when this 202 was served from the\nIdempotency-Key cache rather than executed fresh. Absent on\na fresh request. See TICKET-024.\n",
                "schema": {
                  "enum": [
                    "true"
                  ],
                  "type": "string"
                }
              }
            }
          },
          "400": {
            "content": {
              "application/json": {
                "examples": {
                  "invalid_idempotency_key": {
                    "value": {
                      "error": "Idempotency-Key must be 1..255 printable ASCII characters",
                      "error_details": {
                        "code": "invalid_idempotency_key",
                        "param": "Idempotency-Key",
                        "request_id": "req_01HX9PT2W4Y6NJZAB3CDEF0004",
                        "type": "invalid_request_error"
                      }
                    }
                  },
                  "missing_target": {
                    "value": {
                      "error": "target_locales is required",
                      "error_details": {
                        "code": "missing_target_locales",
                        "param": "target_locales",
                        "request_id": "req_01HX9PT2W4Y6NJZAB3CDEF0001",
                        "type": "invalid_request_error"
                      }
                    }
                  },
                  "unsupported_locale": {
                    "value": {
                      "error": "unsupported locale: jp",
                      "error_details": {
                        "code": "unsupported_locale",
                        "param": "target_locales",
                        "request_id": "req_01HX9PT2W4Y6NJZAB3CDEF0002",
                        "type": "invalid_request_error"
                      }
                    }
                  }
                },
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Invalid request (missing fields, unsupported locale, malformed URL, malformed Idempotency-Key, etc.)."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "402": {
            "content": {
              "application/json": {
                "examples": {
                  "quota": {
                    "value": {
                      "error": "monthly character quota exceeded",
                      "error_details": {
                        "code": "quota_exceeded",
                        "request_id": "req_01HX9PT2W4Y6NJZAB3CDEF0003",
                        "type": "quota_exceeded_error"
                      }
                    }
                  }
                },
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Account has exceeded its plan quota for the month."
          },
          "409": {
            "content": {
              "application/json": {
                "examples": {
                  "idempotency_key_in_progress": {
                    "value": {
                      "error": "a request with this Idempotency-Key is currently being processed; retry shortly",
                      "error_details": {
                        "code": "idempotency_key_in_progress",
                        "param": "Idempotency-Key",
                        "request_id": "req_01HX9PT2W4Y6NJZAB3CDEF0006",
                        "type": "invalid_request_error"
                      }
                    }
                  },
                  "idempotency_key_in_use": {
                    "value": {
                      "error": "Idempotency-Key has already been used with a different request body",
                      "error_details": {
                        "code": "idempotency_key_in_use",
                        "param": "Idempotency-Key",
                        "request_id": "req_01HX9PT2W4Y6NJZAB3CDEF0005",
                        "type": "invalid_request_error"
                      }
                    }
                  }
                },
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "The supplied `Idempotency-Key` cannot currently produce a stable\nresponse. Two cases:\n\n- `idempotency_key_in_use`: the key was previously used with a\n  different request body within the 24h replay window. Either\n  use a new key or re-send the original body byte-for-byte.\n- `idempotency_key_in_progress`: a request with the same key is\n  currently being processed by the server. Retry shortly; the\n  completed response (or its replay) will be available once the\n  in-flight request finishes.\n"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "summary": "Submit a translation job",
        "tags": [
          "Translate"
        ]
      }
    },
    "/v1/usage": {
      "get": {
        "description": "Returns the calling account's character and image counts for the\ncurrent calendar-month billing period (UTC) plus a continuous 30-day\ndaily series suitable for sparkline rendering.\n\n`chars_limit` and `images_limit` are JSON `null` for plans the server\ndoes not enforce a quota against (today: every plan other than\n`free`). Clients should treat `null` as \"no enforced cap.\" The free\ntier is the only plan that returns numeric ceilings — currently\n10,000,000 characters and 2,000 images per period (Validation Pack\nlimits, no trial expiry).\n\n`daily` always contains exactly 30 entries (or fewer for accounts\nyounger than 30 days), one per day, ordered ascending by date. Days\nwith zero usage are present as zero rows so client chart code never\nneeds to interpolate missing dates.\n",
        "operationId": "getUsage",
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "examples": {
                  "free_tier": {
                    "summary": "Free-tier account mid-month",
                    "value": {
                      "chars_limit": 10000000,
                      "chars_used": 12345,
                      "daily": [
                        {
                          "chars_processed": 0,
                          "date": "2026-05-11",
                          "images_processed": 0
                        },
                        {
                          "chars_processed": 12345,
                          "date": "2026-05-12",
                          "images_processed": 7
                        }
                      ],
                      "images_limit": 2000,
                      "images_used": 7,
                      "period_end": "2026-06-01T00:00:00Z",
                      "period_start": "2026-05-01T00:00:00Z",
                      "plan": "free"
                    }
                  },
                  "paid_tier": {
                    "summary": "Paid-tier account (no enforced cap)",
                    "value": {
                      "chars_limit": null,
                      "chars_used": 4200000,
                      "daily": [],
                      "images_limit": null,
                      "images_used": 850,
                      "period_end": "2026-06-01T00:00:00Z",
                      "period_start": "2026-05-01T00:00:00Z",
                      "plan": "starter"
                    }
                  }
                },
                "schema": {
                  "$ref": "#/components/schemas/UsageResponse"
                }
              }
            },
            "description": "Usage snapshot for the calling account."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "summary": "Read this account's current-period quota usage",
        "tags": [
          "Usage"
        ]
      }
    },
    "/v1/webhook-deliveries": {
      "get": {
        "description": "Returns the calling account's webhook delivery attempts in\nnewest-first order with cursor pagination. The response envelope\nis `{data, has_more, next_cursor?}` — the `next_cursor` field is\n**omitted (not null)** when `has_more` is `false`, matching the\nStripe-style wire posture used by `GET /v1/jobs`.\n\nPass the value of `next_cursor` back as the `cursor` query\nparameter to fetch the next page. The cursor is opaque\n(URL-safe base64 of a small JSON object encoding the\n`(delivered_at, id)` tuple of the last row on the current page) —\ndo not parse it on the client side; treat it as a token.\n\nCross-account isolation is enforced server-side via a join\nthrough the `webhooks` table; you cannot read another account's\ndeliveries by guessing a cursor or delivery id.\n\nCustomer-callable redelivery is intentionally NOT exposed — see\nthe Webhooks tag description for why a leaked API key must not\nbe able to trigger arbitrary replays at a customer-controlled\nURL. Manual redelivery exists as a dashboard-only operating\naction.\n",
        "operationId": "listWebhookDeliveries",
        "parameters": [
          {
            "description": "Page size. Default 25. Values above 100 are clamped to 100;\nvalues \u003c= 0 or non-integers return `400 invalid_limit`.\n",
            "example": 25,
            "in": "query",
            "name": "limit",
            "required": false,
            "schema": {
              "default": 25,
              "maximum": 100,
              "minimum": 1,
              "type": "integer"
            }
          },
          {
            "description": "Opaque pagination token returned in the previous page's\n`next_cursor`. Malformed cursors return `400 invalid_cursor`.\n",
            "example": "eyJjIjoiMjAyNi0wNS0xMlQxOTowMDowMFoiLCJpIjoiOGY0YzJjNGQtOGI3My00ZjcxLTllMmMtYjFhZDhmMGE5YjcyIn0",
            "in": "query",
            "name": "cursor",
            "required": false,
            "schema": {
              "type": "string"
            }
          },
          {
            "description": "Filter by terminal status. Invalid values return\n`400 invalid_status`.\n",
            "example": "permanently_failed",
            "in": "query",
            "name": "status",
            "required": false,
            "schema": {
              "enum": [
                "pending",
                "succeeded",
                "permanently_failed"
              ],
              "type": "string"
            }
          },
          {
            "description": "Restrict results to deliveries against a single registered\nwebhook id. Malformed UUIDs return `400 invalid_webhook_id`.\n",
            "example": "8f4c2c4d-8b73-4f71-9e2c-b1ad8f0a9b72",
            "in": "query",
            "name": "webhook_id",
            "required": false,
            "schema": {
              "format": "uuid",
              "type": "string"
            }
          },
          {
            "description": "Return only deliveries with `delivered_at \u003e= since`. RFC3339\ntimestamp; invalid values return `400 invalid_since`.\n",
            "example": "2026-05-01T00:00:00Z",
            "in": "query",
            "name": "since",
            "required": false,
            "schema": {
              "format": "date-time",
              "type": "string"
            }
          }
        ],
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "examples": {
                  "first_page": {
                    "summary": "First page, more rows available",
                    "value": {
                      "data": [
                        {
                          "attempt": 6,
                          "body_preview": "{\"job_id\":\"550e8400-e29b-41d4-a716-446655440000\",\"status\":\"completed\"}",
                          "created_at": "2026-05-12T19:00:00Z",
                          "delivered_at": "2026-05-12T19:00:00Z",
                          "event_id": "11111111-2222-4333-8444-555555555555",
                          "final_status": "permanently_failed",
                          "id": "f1e2d3c4-b5a6-4978-9685-abcdef012345",
                          "job_id": "550e8400-e29b-41d4-a716-446655440000",
                          "last_error": "HTTP 500",
                          "next_attempt_at": null,
                          "redelivered_from": null,
                          "response_code": 500,
                          "webhook_id": "8f4c2c4d-8b73-4f71-9e2c-b1ad8f0a9b72"
                        }
                      ],
                      "has_more": true,
                      "next_cursor": "eyJjIjoiMjAyNi0wNS0xMlQxOTowMDowMFoiLCJpIjoiZjFlMmQzYzQtYjVhNi00OTc4LTk2ODUtYWJjZGVmMDEyMzQ1In0"
                    }
                  },
                  "last_page": {
                    "summary": "Final page, next_cursor absent",
                    "value": {
                      "data": [],
                      "has_more": false
                    }
                  }
                },
                "schema": {
                  "$ref": "#/components/schemas/WebhookDeliveriesList"
                }
              }
            },
            "description": "A page of deliveries. `next_cursor` is omitted when `has_more` is `false`."
          },
          "400": {
            "content": {
              "application/json": {
                "examples": {
                  "invalid_cursor": {
                    "value": {
                      "error": "cursor is not a valid pagination cursor",
                      "error_details": {
                        "code": "invalid_cursor",
                        "param": "cursor",
                        "request_id": "req_01HX9PT2W4Y6NJZAB3CDEF0200",
                        "type": "invalid_request_error"
                      }
                    }
                  },
                  "invalid_status": {
                    "value": {
                      "error": "status must be one of pending, succeeded, permanently_failed",
                      "error_details": {
                        "code": "invalid_status",
                        "param": "status",
                        "request_id": "req_01HX9PT2W4Y6NJZAB3CDEF0201",
                        "type": "invalid_request_error"
                      }
                    }
                  },
                  "invalid_webhook_id": {
                    "value": {
                      "error": "webhook_id must be a valid UUID",
                      "error_details": {
                        "code": "invalid_webhook_id",
                        "param": "webhook_id",
                        "request_id": "req_01HX9PT2W4Y6NJZAB3CDEF0202",
                        "type": "invalid_request_error"
                      }
                    }
                  }
                },
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Malformed query parameter."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "summary": "List webhook deliveries for the calling account",
        "tags": [
          "Webhooks"
        ]
      }
    },
    "/v1/webhooks": {
      "get": {
        "description": "Returns all non-revoked webhooks owned by the calling account.\nOnly `secret_prefix` (first 8 chars) is included — the raw\nsigning secret is NEVER returned by this endpoint.\n",
        "operationId": "listWebhooks",
        "responses": {
          "200": {
            "content": {
              "application/json": {
                "examples": {
                  "empty": {
                    "value": {
                      "data": []
                    }
                  },
                  "two": {
                    "value": {
                      "data": [
                        {
                          "created_at": "2026-05-12T19:00:00Z",
                          "id": "8f4c2c4d-8b73-4f71-9e2c-b1ad8f0a9b72",
                          "secret_prefix": "p4ZxQy8k",
                          "signed": true,
                          "url": "https://example.com/webhooks/fora"
                        },
                        {
                          "created_at": "2026-05-10T11:42:00Z",
                          "id": "1a2b3c4d-5e6f-4a8b-9c0d-1e2f3a4b5c6d",
                          "secret_prefix": "ZxKj92L1",
                          "signed": false,
                          "url": "https://staging.example.com/hooks"
                        }
                      ]
                    }
                  }
                },
                "schema": {
                  "$ref": "#/components/schemas/WebhookList"
                }
              }
            },
            "description": "Webhooks for the calling account."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "summary": "List webhooks for the calling account",
        "tags": [
          "Webhooks"
        ]
      },
      "post": {
        "description": "Creates a new webhook for the calling account and returns a freshly\ngenerated HMAC signing secret. **The raw `secret` is returned\nEXACTLY ONCE** in this response and is never readable again — the\nserver keeps it KMS-encrypted at rest so future outbound deliveries\ncan sign with it (EPIC-005 / TICKET-017), but no GET endpoint\nexposes the plaintext.\n\nAfter creation, only the first 8 characters of the secret are\nretrievable as `secret_prefix`, suitable for identifying the\nwebhook in a UI without leaking the secret itself.\n\nWebhook URL must be `http://` or `https://` with a valid host —\nthe same validation rule applied to the `webhook_url` field on\n`POST /v1/translate`.\n",
        "operationId": "createWebhook",
        "requestBody": {
          "content": {
            "application/json": {
              "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": {
                    "signed": false,
                    "url": "https://legacy.example.com/hooks/fora"
                  }
                }
              },
              "schema": {
                "$ref": "#/components/schemas/CreateWebhookRequest"
              }
            }
          },
          "required": true
        },
        "responses": {
          "201": {
            "content": {
              "application/json": {
                "examples": {
                  "created": {
                    "value": {
                      "created_at": "2026-05-12T19:00:00Z",
                      "id": "8f4c2c4d-8b73-4f71-9e2c-b1ad8f0a9b72",
                      "secret": "whsec_p4ZxQy8k3J9vR2nLmWd1cFxV6tHs0aB7",
                      "secret_prefix": "p4ZxQy8k",
                      "signed": true,
                      "url": "https://example.com/webhooks/fora"
                    }
                  }
                },
                "schema": {
                  "$ref": "#/components/schemas/WebhookCreated"
                }
              }
            },
            "description": "Webhook created. The raw `secret` field appears here ONCE — store it immediately."
          },
          "400": {
            "content": {
              "application/json": {
                "examples": {
                  "invalid_url": {
                    "value": {
                      "error": "url must be an http or https URL with a valid host",
                      "error_details": {
                        "code": "invalid_webhook_url",
                        "param": "url",
                        "request_id": "req_01HX9PT2W4Y6NJZAB3CDEF0081",
                        "type": "invalid_request_error"
                      }
                    }
                  },
                  "missing_url": {
                    "value": {
                      "error": "url is required",
                      "error_details": {
                        "code": "missing_webhook_url",
                        "param": "url",
                        "request_id": "req_01HX9PT2W4Y6NJZAB3CDEF0080",
                        "type": "invalid_request_error"
                      }
                    }
                  }
                },
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Missing or malformed URL."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "summary": "Register a webhook endpoint",
        "tags": [
          "Webhooks"
        ]
      }
    },
    "/v1/webhooks/{id}": {
      "delete": {
        "description": "Marks the webhook as revoked (sets `revoked_at = NOW()`).\nIdempotent — re-deleting an already-revoked webhook still\nreturns `204`. A webhook that does not belong to the calling\naccount returns `404` (cross-account opacity).\n",
        "operationId": "deleteWebhook",
        "parameters": [
          {
            "description": "The webhook UUID returned from `POST /v1/webhooks`.",
            "in": "path",
            "name": "id",
            "required": true,
            "schema": {
              "example": "8f4c2c4d-8b73-4f71-9e2c-b1ad8f0a9b72",
              "format": "uuid",
              "type": "string"
            }
          }
        ],
        "responses": {
          "204": {
            "description": "Webhook revoked (or already revoked — both succeed)."
          },
          "400": {
            "content": {
              "application/json": {
                "examples": {
                  "invalid_id": {
                    "value": {
                      "error": "invalid webhook id",
                      "error_details": {
                        "code": "invalid_webhook_id",
                        "param": "id",
                        "request_id": "req_01HX9PT2W4Y6NJZAB3CDEF0082",
                        "type": "invalid_request_error"
                      }
                    }
                  }
                },
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Malformed webhook id."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "content": {
              "application/json": {
                "examples": {
                  "not_found": {
                    "value": {
                      "error": "webhook not found",
                      "error_details": {
                        "code": "webhook_not_found",
                        "request_id": "req_01HX9PT2W4Y6NJZAB3CDEF0083",
                        "type": "not_found_error"
                      }
                    }
                  }
                },
                "schema": {
                  "$ref": "#/components/schemas/ErrorResponse"
                }
              }
            },
            "description": "Webhook not found, or not owned by the calling account."
          },
          "500": {
            "$ref": "#/components/responses/InternalError"
          }
        },
        "security": [
          {
            "bearerAuth": []
          }
        ],
        "summary": "Revoke a webhook",
        "tags": [
          "Webhooks"
        ]
      }
    }
  },
  "security": [
    {
      "bearerAuth": []
    }
  ],
  "servers": [
    {
      "description": "Production",
      "url": "https://api.getfora.ai"
    }
  ],
  "tags": [
    {
      "description": "Submit translation jobs and fetch their results.",
      "name": "Translate"
    },
    {
      "description": "Account creation and demo provisioning.",
      "name": "Accounts"
    },
    {
      "description": "Configure customer-owned S3 storage for translated images.",
      "name": "Storage"
    },
    {
      "description": "Heartbeats from the customer-deployed Postgres connector, plus a\nread endpoint for the calling account's registered connectors.\n",
      "name": "Connector"
    },
    {
      "description": "Register, list, and revoke per-account webhook endpoints. Each\nwebhook gets its own HMAC signing secret returned ONCE on create.\nFoundation for EPIC-005 — signing, retry curve, and the deliveries\nsurface build on this resource.\n\n### Outbound delivery headers\n\nEvery outbound webhook POST from Fora to your registered URL carries\nthese request headers in addition to `Content-Type: application/json`:\n\n| Header | When | Value |\n|---|---|---|\n| `Fora-Event-Id` | Always | UUIDv4. Stable across all retry attempts of a single delivery; new for each fresh job. Use as your dedupe key. |\n| `Fora-Signature` | Webhook is `signed: true` | `t=\u003cunix-seconds\u003e,v1=\u003chex\u003e` where `\u003chex\u003e` is the lowercase hex of `HMAC-SHA256(secret_bytes, \u003ct\u003e.\u003craw_body\u003e)`. The `\u003craw_body\u003e` is the exact bytes of the request body — verify against the raw bytes you receive, NOT a re-marshalled JSON. |\n\nThe signing scheme mirrors Stripe's `t=…,v1=…` format. The `v1` prefix\nreserves room for future scheme versions without breaking existing\nverifiers. See `https://getfora.ai/docs/webhooks` for runnable Node\nand Python verification snippets pinned to the same fixed test vector\nas the server's unit tests.\n\nLegacy jobs that pass `webhook_url` on `POST /v1/translate` without\nhaving registered a corresponding webhook are delivered as\n`signed: false` (the server auto-creates a row on first delivery).\nTo opt into signing, register the same URL via `POST /v1/webhooks`\nwith the default `signed: true` and store the returned `secret`.\n\n### Retry curve\n\nThe first delivery attempt happens inline immediately after the\njob is marked complete (the success path is one round-trip — no\nadded latency on the happy case). If the inline attempt fails or\nreturns a non-2xx status, the delivery is persisted and a\nbackground worker re-fires it on the curve below:\n\n| Attempt | Wait before this attempt |\n|---|---|\n| 1 | inline (no wait) |\n| 2 | 1 minute |\n| 3 | 5 minutes |\n| 4 | 30 minutes |\n| 5 | 2 hours |\n| 6 | 6 hours |\n\nTotal budget across the 6 attempts is approximately 8.6 hours\n(1m + 5m + 30m + 2h + 6h between successive attempts), designed\nto survive realistic transient outages (deploy, ALB blip,\nbrief 5xx). After the 6th failed attempt the delivery is marked\n`permanently_failed` and is no longer retried; the read API for\ndelivery history will surface this state in a future ticket.\n\n`Fora-Event-Id` is stable across all attempts of a single\ndelivery — use it as your dedupe key. `Fora-Signature` is\nrecomputed per attempt because the `t=\u003cunix-seconds\u003e` component\nshifts; verify against the raw bytes you receive on each call.\n",
      "name": "Webhooks"
    },
    {
      "description": "Read access to the calling account's translation jobs plus a\nfirst-class retry primitive. Cursor-paginated list endpoint\n(`GET /v1/jobs`) shipped in TICKET-025; the per-job get\n(`GET /v1/jobs/{id}`) and retry (`POST /v1/jobs/{id}/retry`)\nendpoints shipped in TICKET-026.\n\n`GET /v1/jobs/{id}` returns the full job record with per-locale\n`translations` inlined under a single key (Stripe-style \"expanded\nresource\"). `POST /v1/jobs/{id}/retry` re-submits the original\npayload as a brand-new job; the response shape mirrors\n`POST /v1/translate` so client code can treat the two entrypoints\ninterchangeably.\n\nRetries honor the same `Idempotency-Key` middleware as\n`POST /v1/translate` — send the same key within 24h to dedupe a\nduplicate retry click without producing a second job.\n",
      "name": "Jobs"
    },
    {
      "description": "Read-only view of the calling account's quota usage for the current\nmonthly billing period plus a 30-day daily series. Mirrors the data the\nFora dashboard's usage panel renders, scoped to the calling account so\ncustomers can build their own ops console on the same numbers.\n",
      "name": "Usage"
    },
    {
      "description": "Manage the calling account's named API keys. Mirrors the dashboard\n\"API keys\" view: list, create, revoke. The raw `api_key` field is\nreturned ONCE on `POST /v1/keys` and never again — `GET /v1/keys`\nsurfaces only `key_prefix`. Use these endpoints to provision a CI\nkey, a staging key, or to rotate compromised credentials\nprogrammatically.\n\nThe legacy primary key on the account itself (returned by\n`POST /v1/accounts` and rotated via the dashboard) is **not**\nrepresented here — these endpoints manage only the named, revocable\nkeys created by `POST /v1/keys` (or the dashboard equivalent).\n\n### Self-revoke is forbidden\n\n`DELETE /v1/keys/{id}` returns `409 cannot_revoke_self` if the\n`{id}` matches the api_keys row id of the key used to authenticate\nthe request. To revoke the calling key, authenticate with a\ndifferent key (or rotate via the dashboard). This prevents a\none-key account from accidentally locking itself out.\n",
      "name": "Keys"
    },
    {
      "description": "Self-serve API contract (this document).",
      "name": "Spec"
    }
  ]
}