openapi: 3.1.0
info:
  title: FoodShot Partner API
  version: 1.0.0
  summary: Turn a photo of a dish into a professional food photo in a FoodShot style.
  description: |
    The FoodShot Partner API turns a photo of a dish into a professional food photo in a FoodShot style.

    > **The quality of your photo strongly affects the result.** Read the [Photo guide](/photo-guide) before you send real photos.

    ## How it works

    1. Send a photo and a `style_id` to `POST /v1/generations`, with an `Idempotency-Key`. You get `202 Accepted` and a generation id.
    2. Wait for the `generation.succeeded` webhook, or poll `GET /v1/generations/{id}`. Our objective is about 30–40 seconds once a job starts.
    3. Download `output.url` from your server and keep your own copy.

    ## Basics

    - Base URL `https://api.foodshot.ai`. Every path starts with `/v1`.
    - Send `Authorization: Bearer <key>`. Test keys (`fs_test_`) are free and return sample images. Live keys (`fs_live_`) use credits and work only from your server.
    - JSON in and out, snake_case names, UTC timestamps.
    - Retry timeouts, `429` and `5xx` with the same `Idempotency-Key` and the same body.
    - Within `/v1` we may add fields, event types, error codes and some values. Ignore the ones you don't know.

    ## Credits

    1 credit = 1 finished image (any style, ratio, 2K or 4K); failed jobs are free; prepaid; added within 1 business day of payment.

    ## Data

    Servers and storage: USA. Operations: Türkiye. Image-generation sub-processors may process or cache data in other countries. We do not train models on your images; a sub-processor may use only anonymised, aggregated usage data.

    Guides, limits and policies: [docs.foodshot.ai](https://docs.foodshot.ai).
  termsOfService: https://docs.foodshot.ai/policies/terms
  contact:
    name: FoodShot
    email: hello@foodshot.ai
    url: https://docs.foodshot.ai
  license:
    name: Proprietary. Use is governed by the FoodShot Partner API Terms.
    url: https://docs.foodshot.ai/policies/terms
externalDocs:
  description: Guides, the photo guide and policies
  url: https://docs.foodshot.ai
servers:
  - url: https://api.foodshot.ai
    description: Live and test. The key you send picks the environment.
security:
  - ApiKey: []
tags:
  - name: Generations
    description: Create a generation from a photo, get its result and list past generations.
  - name: Styles
    description: The styles you can use.
  - name: Account
    description: Credits, limits and usage.
  - name: Files
    description: Result files.
  - name: Health
    description: A public health check.
  - name: Webhooks
    description: |
      Signed events we send to your endpoint, following [Standard Webhooks](https://www.standardwebhooks.com/). Verify the signature on the raw body, answer `2xx` within 10 seconds and handle each `webhook-id` once. See [Webhooks](/guides/webhooks).
paths:
  /v1/generations:
    post:
      operationId: createGeneration
      summary: Create generation
      tags: [Generations]
      description: |
        > **The quality of your photo strongly affects the result.** See the [Photo guide](/photo-guide).

        Creates a generation from one photo. The answer is `202 Accepted` with the generation, usually within seconds, and the job runs in the background. Our objective is about 30–40 seconds once it starts.

        Every request needs an `Idempotency-Key`: one UUID per photo, reused with the same body when you retry. A live job takes 1 credit, and a failed job is refunded.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKey'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/CreateGenerationRequest'
            examples:
              style_from_url:
                summary: A style, photo by URL (our sample dish photo)
                value:
                  image_url: https://docs.foodshot.ai/samples/dish.jpg
                  style_id: pure-shot
                  aspect_ratio: '1:1'
                  metadata:
                    dish_id: d_456
                    vendor_id: v_1029
              style_and_instruction_base64:
                summary: A style plus an instruction, photo as base64, 4K PNG
                value:
                  image_base64: /9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/
                  style_id: pure-shot
                  instruction: Keep the blue bowl
                  aspect_ratio: '4:5'
                  resolution: 4k
                  format: png
              instruction_only:
                summary: An instruction without a style, at 2K
                value:
                  image_url: https://docs.foodshot.ai/samples/dish.jpg
                  instruction: Bright daylight on a light wooden table, seen from above
                  aspect_ratio: '16:9'
                  resolution: 2k
                  metadata:
                    dish_id: d_458
              test_key_scenario:
                summary: Test key, a forced content_refused failure and a one-off webhook URL
                value:
                  image_url: https://docs.foodshot.ai/samples/dish.jpg
                  style_id: pure-shot
                  test_scenario: content_refused
                  webhook_url: https://staging.example.com/foodshot/webhook
      responses:
        '202':
          description: |
            Accepted: the new generation, `queued`. A retry with the same `Idempotency-Key` and body returns the same generation, with `Idempotent-Replayed: true`, and isn't charged again.
          headers:
            FoodShot-Request-Id:
              $ref: '#/components/headers/RequestId'
            Location:
              $ref: '#/components/headers/Location'
            Idempotent-Replayed:
              $ref: '#/components/headers/IdempotentReplayed'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Generation'
              examples:
                queued_live:
                  $ref: '#/components/examples/GenerationQueued'
                queued_instruction_2k:
                  $ref: '#/components/examples/GenerationQueuedInstruction2k'
                replayed_succeeded:
                  summary: A replay of a request whose job has already succeeded
                  $ref: '#/components/examples/GenerationSucceeded'
        '400':
          $ref: '#/components/responses/BadRequestCreate'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '402':
          $ref: '#/components/responses/PaymentRequired'
        '403':
          $ref: '#/components/responses/Forbidden'
        '409':
          $ref: '#/components/responses/Conflict'
        '413':
          $ref: '#/components/responses/PayloadTooLarge'
        '415':
          $ref: '#/components/responses/UnsupportedMediaType'
        '429':
          $ref: '#/components/responses/TooManyRequestsCreate'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailableCreate'
      x-codeSamples:
        - lang: bash
          label: curl
          source: |
            # One Idempotency-Key per photo. When you retry,
            # send the same key and the identical body.
            # The sample dish photo is ours: use your photo's URL.
            curl -sS https://api.foodshot.ai/v1/generations \
              --max-time 60 \
              -H "Authorization: Bearer $FOODSHOT_API_KEY" \
              -H 'Content-Type: application/json' \
              -H 'Idempotency-Key: 5f0c2b7e-9a4d-4c1b-8e3f-6d2a1b9c7e40' \
              -d '{
                "image_url": "https://docs.foodshot.ai/samples/dish.jpg",
                "style_id": "pure-shot",
                "aspect_ratio": "1:1",
                "metadata": { "dish_id": "d_456" }
              }'
        - lang: js
          label: Node 22
          source: |
            // Node 22 (ES module). One Idempotency-Key per photo: store it with the photo,
            // and send it again, with the identical body, when you retry.
            // The sample dish photo is ours: use your photo's https URL.
            import { randomUUID } from 'node:crypto';

            const idempotencyKey = randomUUID();
            const res = await fetch('https://api.foodshot.ai/v1/generations', {
              method: 'POST',
              headers: {
                Authorization: `Bearer ${process.env.FOODSHOT_API_KEY}`,
                'Content-Type': 'application/json',
                'Idempotency-Key': idempotencyKey,
              },
              body: JSON.stringify({
                image_url: 'https://docs.foodshot.ai/samples/dish.jpg',
                style_id: 'pure-shot',
                aspect_ratio: '1:1',
                metadata: { dish_id: 'd_456' },
              }),
              signal: AbortSignal.timeout(60_000),
            });
            const text = await res.text();
            if (res.status !== 202) throw new Error(`FoodShot ${res.status}: ${text}`);
            const generation = JSON.parse(text);
            console.log(generation.id, generation.status); // gen_…, queued
        - lang: python
          label: Python
          source: |
            import os
            import uuid

            import requests

            # One Idempotency-Key per photo: store it with the photo, and send it again,
            # with the identical body, when you retry.
            # The sample dish photo is ours: use your photo's https URL.
            idempotency_key = str(uuid.uuid4())
            response = requests.post(
                "https://api.foodshot.ai/v1/generations",
                headers={
                    "Authorization": f"Bearer {os.environ['FOODSHOT_API_KEY']}",
                    "Idempotency-Key": idempotency_key,
                },
                json={
                    "image_url": "https://docs.foodshot.ai/samples/dish.jpg",
                    "style_id": "pure-shot",
                    "aspect_ratio": "1:1",
                    "metadata": {"dish_id": "d_456"},
                },
                timeout=60,
            )
            if response.status_code != 202:
                raise RuntimeError(f"FoodShot {response.status_code}: {response.text}")
            generation = response.json()
            print(generation["id"], generation["status"])  # gen_…, queued
    get:
      operationId: listGenerations
      summary: List generations
      tags: [Generations]
      description: |
        Lists your generations from the last 90 days, newest first. While `has_more` is `true`, pass the `id` of the last item as `starting_after` to get the next page.
      parameters:
        - $ref: '#/components/parameters/ListStatus'
        - $ref: '#/components/parameters/ListCreatedGte'
        - $ref: '#/components/parameters/ListCreatedLt'
        - $ref: '#/components/parameters/ListLimit'
        - $ref: '#/components/parameters/ListStartingAfter'
      responses:
        '200':
          description: One page of generations, newest first.
          headers:
            FoodShot-Request-Id:
              $ref: '#/components/headers/RequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/GenerationList'
              examples:
                page:
                  $ref: '#/components/examples/GenerationListPage'
        '400':
          $ref: '#/components/responses/BadRequestQuery'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/TooManyRequestsRead'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailableRead'
      x-codeSamples:
        - lang: bash
          label: curl
          source: |
            curl -sS -G https://api.foodshot.ai/v1/generations \
              --max-time 60 \
              -H "Authorization: Bearer $FOODSHOT_API_KEY" \
              --data-urlencode 'status=succeeded' \
              --data-urlencode 'created_gte=2026-10-01T00:00:00Z' \
              --data-urlencode 'limit=100'
        - lang: js
          label: Node 22
          source: |
            // Node 22 (ES module). Every succeeded generation since 1 October, newest first.
            const params = new URLSearchParams({
              status: 'succeeded',
              created_gte: '2026-10-01T00:00:00Z',
              limit: '100',
            });
            let startingAfter;
            do {
              if (startingAfter) params.set('starting_after', startingAfter);
              const res = await fetch(`https://api.foodshot.ai/v1/generations?${params}`, {
                headers: { Authorization: `Bearer ${process.env.FOODSHOT_API_KEY}` },
                signal: AbortSignal.timeout(60_000),
              });
              if (!res.ok) throw new Error(`FoodShot ${res.status}: ${await res.text()}`);
              const page = await res.json();
              for (const generation of page.data) console.log(generation.id, generation.metadata);
              startingAfter = page.has_more ? page.data.at(-1).id : undefined;
            } while (startingAfter);
        - lang: python
          label: Python
          source: |
            import os

            import requests

            # Every succeeded generation since 1 October, newest first.
            params = {"status": "succeeded", "created_gte": "2026-10-01T00:00:00Z", "limit": 100}
            headers = {"Authorization": f"Bearer {os.environ['FOODSHOT_API_KEY']}"}
            while True:
                response = requests.get(
                    "https://api.foodshot.ai/v1/generations", params=params, headers=headers, timeout=60
                )
                response.raise_for_status()
                page = response.json()
                for generation in page["data"]:
                    print(generation["id"], generation["metadata"])
                if not page["has_more"]:
                    break
                params["starting_after"] = page["data"][-1]["id"]
  /v1/generations/{id}:
    get:
      operationId: getGeneration
      summary: Get generation
      tags: [Generations]
      description: |
        Returns one generation, with a fresh `output.url` on every call. To follow a job, use webhooks, or poll about 20 seconds after you create it, then every 5 seconds.
      parameters:
        - $ref: '#/components/parameters/GenerationId'
      responses:
        '200':
          description: The generation.
          headers:
            FoodShot-Request-Id:
              $ref: '#/components/headers/RequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Generation'
              examples:
                succeeded:
                  $ref: '#/components/examples/GenerationSucceeded'
                processing:
                  $ref: '#/components/examples/GenerationProcessing'
                failed:
                  $ref: '#/components/examples/GenerationFailed'
                test_sample:
                  $ref: '#/components/examples/GenerationTestSucceeded'
        '400':
          $ref: '#/components/responses/BadRequestQuery'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '404':
          $ref: '#/components/responses/NotFoundGeneration'
        '429':
          $ref: '#/components/responses/TooManyRequestsRead'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailableRead'
      x-codeSamples:
        - lang: bash
          label: curl
          source: |
            # GENERATION_ID is the id that POST /v1/generations returned.
            curl -sS "https://api.foodshot.ai/v1/generations/$GENERATION_ID" \
              --max-time 60 \
              -H "Authorization: Bearer $FOODSHOT_API_KEY"
        - lang: js
          label: Node 22
          source: |
            // Node 22 (ES module). Poll until the generation is final.
            import { setTimeout as sleep } from 'node:timers/promises';

            const id = process.env.GENERATION_ID; // the id that POST /v1/generations returned
            await sleep(20_000); // first poll after about 20 seconds
            for (;;) {
              const res = await fetch(`https://api.foodshot.ai/v1/generations/${id}`, {
                headers: { Authorization: `Bearer ${process.env.FOODSHOT_API_KEY}` },
                signal: AbortSignal.timeout(60_000),
              });
              if (res.status === 429 || res.status >= 500) {
                await sleep(Number(res.headers.get('retry-after') ?? 5) * 1000);
                continue;
              }
              if (!res.ok) throw new Error(`FoodShot ${res.status}: ${await res.text()}`);
              const generation = await res.json();
              if (generation.status === 'succeeded') {
                console.log(generation.output.url); // a 24-hour link: download it from your server
                break;
              }
              if (generation.status === 'failed') {
                console.log(generation.error.code); // the credit was refunded
                break;
              }
              await sleep(5_000); // then every 5 seconds
            }
        - lang: python
          label: Python
          source: |
            import os
            import time

            import requests

            # Poll until the generation is final.
            generation_id = os.environ["GENERATION_ID"]  # the id that POST /v1/generations returned
            headers = {"Authorization": f"Bearer {os.environ['FOODSHOT_API_KEY']}"}
            time.sleep(20)  # first poll after about 20 seconds
            while True:
                response = requests.get(
                    f"https://api.foodshot.ai/v1/generations/{generation_id}", headers=headers, timeout=60
                )
                if response.status_code == 429 or response.status_code >= 500:
                    time.sleep(int(response.headers.get("Retry-After", "5")))
                    continue
                response.raise_for_status()
                generation = response.json()
                if generation["status"] == "succeeded":
                    print(generation["output"]["url"])  # a 24-hour link: download it from your server
                    break
                if generation["status"] == "failed":
                    print(generation["error"]["code"])  # the credit was refunded
                    break
                time.sleep(5)  # then every 5 seconds
  /v1/styles:
    get:
      operationId: listStyles
      summary: List styles
      tags: [Styles]
      description: |
        Lists the styles you can use, category by category. Add `category` to get one category. `description`, `angle` and `recommended_aspect_ratios` can be `null`.
      parameters:
        - $ref: '#/components/parameters/StyleCategory'
      responses:
        '200':
          description: The styles your account can use, or those of one category.
          headers:
            FoodShot-Request-Id:
              $ref: '#/components/headers/RequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/StyleList'
              examples:
                styles:
                  $ref: '#/components/examples/StyleListExample'
        '400':
          $ref: '#/components/responses/BadRequestStyles'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/TooManyRequestsRead'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailableRead'
      x-codeSamples:
        - lang: bash
          label: curl
          source: |
            # The styles of one category; leave out the
            # last line to get every style.
            curl -sS -G https://api.foodshot.ai/v1/styles \
              --max-time 60 \
              -H "Authorization: Bearer $FOODSHOT_API_KEY" \
              --data-urlencode 'category=delivery-apps'
        - lang: js
          label: Node 22
          source: |
            // Node 22 (ES module). The styles of one category; leave out
            // ?category=... to get every style.
            const res = await fetch('https://api.foodshot.ai/v1/styles?category=delivery-apps', {
              headers: { Authorization: `Bearer ${process.env.FOODSHOT_API_KEY}` },
              signal: AbortSignal.timeout(60_000),
            });
            if (!res.ok) throw new Error(`FoodShot ${res.status}: ${await res.text()}`);
            const { data: styles } = await res.json();
            for (const style of styles) {
              // description, angle and recommended_aspect_ratios can be null.
              console.log(style.id, style.name, style.category_name, style.angle ?? '-');
            }
        - lang: python
          label: Python
          source: |
            import os

            import requests

            # The styles of one category; leave out params to get every style.
            response = requests.get(
                "https://api.foodshot.ai/v1/styles",
                params={"category": "delivery-apps"},
                headers={"Authorization": f"Bearer {os.environ['FOODSHOT_API_KEY']}"},
                timeout=60,
            )
            response.raise_for_status()
            for style in response.json()["data"]:
                # description, angle and recommended_aspect_ratios can be None.
                print(style["id"], style["name"], style["category_name"], style["angle"] or "-")
  /v1/account:
    get:
      operationId: getAccount
      summary: Get account
      tags: [Account]
      description: |
        Returns the credits, limits and usage of your key's environment. `credits` is `null` for test keys.
      parameters: []
      responses:
        '200':
          description: The account.
          headers:
            FoodShot-Request-Id:
              $ref: '#/components/headers/RequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Account'
              examples:
                live_key:
                  $ref: '#/components/examples/AccountLive'
                test_key:
                  $ref: '#/components/examples/AccountTest'
        '400':
          $ref: '#/components/responses/BadRequestQuery'
        '401':
          $ref: '#/components/responses/Unauthorized'
        '403':
          $ref: '#/components/responses/Forbidden'
        '429':
          $ref: '#/components/responses/TooManyRequestsRead'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailableRead'
      x-codeSamples:
        - lang: bash
          label: curl
          source: |
            curl -sS https://api.foodshot.ai/v1/account \
              --max-time 60 \
              -H "Authorization: Bearer $FOODSHOT_API_KEY"
        - lang: js
          label: Node 22
          source: |
            // Node 22 (ES module).
            const res = await fetch('https://api.foodshot.ai/v1/account', {
              headers: { Authorization: `Bearer ${process.env.FOODSHOT_API_KEY}` },
              signal: AbortSignal.timeout(60_000),
            });
            if (!res.ok) throw new Error(`FoodShot ${res.status}: ${await res.text()}`);
            const account = await res.json();
            if (account.credits?.low_balance) {
              console.warn(`Low balance: ${account.credits.available} credits left`);
            }
            console.log(account.environment, account.limits.jobs_per_day, account.usage.today);
        - lang: python
          label: Python
          source: |
            import os

            import requests

            response = requests.get(
                "https://api.foodshot.ai/v1/account",
                headers={"Authorization": f"Bearer {os.environ['FOODSHOT_API_KEY']}"},
                timeout=60,
            )
            response.raise_for_status()
            account = response.json()
            credits = account["credits"]  # None for test keys
            if credits and credits["low_balance"]:
                print(f"Low balance: {credits['available']} credits left")
            print(account["environment"], account["limits"]["jobs_per_day"], account["usage"]["today"])
  /v1/health:
    get:
      operationId: getHealth
      summary: Health
      tags: [Health]
      security: []
      description: |
        Returns `200` with `{"status": "ok"}` when the API is healthy, and `503` otherwise. No key needed.
      parameters: []
      responses:
        '200':
          description: The API is healthy.
          headers:
            FoodShot-Request-Id:
              $ref: '#/components/headers/RequestId'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Health'
              examples:
                ok:
                  summary: Healthy
                  value:
                    status: ok
        '400':
          $ref: '#/components/responses/BadRequestQuery'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      x-codeSamples:
        - lang: bash
          label: curl
          source: |
            curl -sS --max-time 60 https://api.foodshot.ai/v1/health
        - lang: js
          label: Node 22
          source: |
            // Node 22 (ES module). No key needed.
            const res = await fetch('https://api.foodshot.ai/v1/health', {
              signal: AbortSignal.timeout(60_000),
            });
            console.log(res.status, await res.json()); // 200 { status: 'ok' }
        - lang: python
          label: Python
          source: |
            import requests

            # No key needed.
            response = requests.get("https://api.foodshot.ai/v1/health", timeout=60)
            print(response.status_code, response.json())  # 200 {'status': 'ok'}
  /v1/files/{generation_id}/image.{ext}:
    get:
      operationId: downloadFile
      summary: Download file
      tags: [Files]
      security: []
      description: |
        Downloads a result file. Use `output.url` exactly as given: it needs no key and works for 24 hours. Files are kept for 30 days. Download from your server: a download is cut off after 60 seconds.
      parameters:
        - $ref: '#/components/parameters/FileGenerationId'
        - $ref: '#/components/parameters/FileExtension'
        - $ref: '#/components/parameters/FileExpires'
        - $ref: '#/components/parameters/FileSignature'
      responses:
        '200':
          description: The result file, streamed.
          headers:
            FoodShot-Request-Id:
              $ref: '#/components/headers/RequestId'
            Cache-Control:
              $ref: '#/components/headers/CacheControlPrivate'
          content:
            image/jpeg: {}
            image/png: {}
        '400':
          $ref: '#/components/responses/BadRequestQuery'
        '403':
          $ref: '#/components/responses/ForbiddenFile'
        '404':
          $ref: '#/components/responses/NotFoundFile'
        '500':
          $ref: '#/components/responses/InternalError'
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
      x-codeSamples:
        - lang: bash
          label: curl
          source: |
            # FOODSHOT_OUTPUT_URL is output.url from the generation. Quote it: it contains '&'.
            # Name the file after output.content_type: image/jpeg is .jpg, image/png is .png.
            curl -sS --fail --max-time 60 -o result.jpg "$FOODSHOT_OUTPUT_URL"
        - lang: js
          label: Node 22
          source: |
            // Node 22 (ES module). Download from your server; the link needs no key.
            import { createWriteStream } from 'node:fs';
            import { Readable } from 'node:stream';
            import { pipeline } from 'node:stream/promises';

            const url = process.env.FOODSHOT_OUTPUT_URL; // output.url, valid for 24 hours
            const res = await fetch(url, { signal: AbortSignal.timeout(60_000) });
            if (!res.ok) throw new Error(`Download failed: ${res.status}`);
            const extension = res.headers.get('content-type') === 'image/png' ? 'png' : 'jpg';
            await pipeline(Readable.fromWeb(res.body), createWriteStream(`result.${extension}`));
        - lang: python
          label: Python
          source: |
            import os

            import requests

            # Download from your server; the link needs no key.
            url = os.environ["FOODSHOT_OUTPUT_URL"]  # output.url, valid for 24 hours
            with requests.get(url, stream=True, timeout=60) as response:
                response.raise_for_status()
                extension = "png" if response.headers.get("Content-Type") == "image/png" else "jpg"
                with open(f"result.{extension}", "wb") as file:
                    for chunk in response.iter_content(chunk_size=1 << 20):
                        file.write(chunk)
webhooks:
  generation.succeeded:
    post:
      operationId: generationSucceededWebhook
      summary: generation.succeeded
      tags: [Webhooks]
      security: []
      description: |
        Sent when a generation succeeds. `data` is the generation, with a fresh `output.url`.

        Verify the signature, answer `2xx` within 10 seconds, then download the file from your server.
      parameters:
        - $ref: '#/components/parameters/WebhookId'
        - $ref: '#/components/parameters/WebhookTimestamp'
        - $ref: '#/components/parameters/WebhookSignature'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/GenerationSucceededEvent'
            examples:
              succeeded:
                summary: A live generation succeeded
                value:
                  id: evt_4Kq9xT2mVb7Zp1Ls8Hc3Rw
                  type: generation.succeeded
                  environment: live
                  created_at: '2026-10-18T10:42:42.901Z'
                  data:
                    id: gen_4Kq9xT2mVb7Zp1Ls8Hc3Rw
                    object: generation
                    environment: live
                    status: succeeded
                    created_at: '2026-10-18T10:42:07.512Z'
                    started_at: '2026-10-18T10:42:09.031Z'
                    completed_at: '2026-10-18T10:42:42.870Z'
                    style_id: pure-shot
                    instruction: null
                    aspect_ratio: '1:1'
                    resolution: 4k
                    format: jpeg
                    metadata:
                      dish_id: d_456
                      vendor_id: v_1029
                    credits_charged: 1
                    output:
                      url: https://api.foodshot.ai/v1/files/gen_4Kq9xT2mVb7Zp1Ls8Hc3Rw/image.jpg?exp=1792406563&sig=8fwqDeaHcFOU4b68TzesgCYKN8NG02QP3yMTmmVM9Pc
                      url_expires_at: '2026-10-19T10:42:43.000Z'
                      available_until: '2026-11-17T10:42:42.870Z'
                      content_type: image/jpeg
                      width: 4096
                      height: 4096
                      bytes: 8734516
                    error: null
                    delivery:
                      status: pending
                      attempts: 0
                      last_status_code: null
                      last_attempt_at: null
      responses:
        '2XX':
          $ref: '#/components/responses/WebhookAccepted'
        default:
          $ref: '#/components/responses/WebhookRetried'
      x-codeSamples: &webhookReceiverSamples
        - lang: bash
          label: curl
          source: |
            # Send yourself a correctly signed test event to check your endpoint's verification code.
            # Needs openssl and od, which macOS and Linux include.
            SECRET="$FOODSHOT_WEBHOOK_SECRET"  # whsec_… of the environment you test
            URL='https://staging.example.com/foodshot/webhook'
            TS=$(date +%s)
            ID="evt_selftest_$TS"
            BODY='{"id":"'"$ID"'","type":"webhook.test","environment":"test","created_at":"2026-10-18T10:42:07.512Z","data":{"object":"webhook_test","message":"This is a test event from FoodShot."}}'
            KEY_HEX=$(printf '%s' "${SECRET#whsec_}" | openssl base64 -d -A | od -An -vtx1 | tr -d ' \n')
            SIG=$(printf '%s' "$ID.$TS.$BODY" | openssl dgst -sha256 -mac HMAC -macopt "hexkey:$KEY_HEX" -binary | openssl base64 -A)
            curl -sS -i --max-time 60 "$URL" \
              -H 'Content-Type: application/json' \
              -H "webhook-id: $ID" \
              -H "webhook-timestamp: $TS" \
              -H "webhook-signature: v1,$SIG" \
              --data-binary "$BODY"
        - lang: js
          label: Node 22
          source: |
            // Node 22 (ES module): receive and verify FoodShot webhooks, no dependencies.
            // The ready-made `standardwebhooks` package on npm does the same check.
            import { createServer } from 'node:http';
            import { createHmac, timingSafeEqual } from 'node:crypto';

            const secret = process.env.FOODSHOT_WEBHOOK_SECRET; // whsec_… of this environment
            const key = Buffer.from(secret.slice('whsec_'.length), 'base64');
            const TOLERANCE_SECONDS = 5 * 60;
            const handled = new Set(); // keep handled event ids in your database instead

            function isSignedByFoodShot(headers, rawBody) {
              const id = headers['webhook-id'];
              const timestamp = headers['webhook-timestamp'];
              const signatures = headers['webhook-signature'];
              if (!id || !timestamp || !signatures) return false;
              const seconds = Number(timestamp);
              if (!Number.isInteger(seconds) || Math.abs(Date.now() / 1000 - seconds) > TOLERANCE_SECONDS) {
                return false;
              }
              const expected = createHmac('sha256', key).update(`${id}.${timestamp}.`).update(rawBody).digest();
              // For 24 hours after a secret rotation there are two signatures, separated by a space.
              return signatures.split(' ').some((entry) => {
                const [version, value] = entry.split(',');
                if (version !== 'v1' || !value) return false;
                const given = Buffer.from(value, 'base64');
                return given.length === expected.length && timingSafeEqual(given, expected);
              });
            }

            createServer((req, res) => {
              const chunks = [];
              req.on('data', (chunk) => chunks.push(chunk));
              req.on('end', () => {
                const rawBody = Buffer.concat(chunks); // verify the raw bytes, before JSON.parse
                if (!isSignedByFoodShot(req.headers, rawBody)) {
                  res.writeHead(400).end();
                  return;
                }
                res.writeHead(204).end(); // answer within 10 seconds, then do the work
                const event = JSON.parse(rawBody);
                if (handled.has(event.id)) return; // the same event can arrive more than once
                handled.add(event.id);
                if (event.type === 'generation.succeeded') {
                  // Download event.data.output.url from your server, then keep your own copy.
                } else if (event.type === 'generation.failed') {
                  // event.data.error.code says why; the credit was refunded.
                }
                // Ignore event types you do not know.
              });
            }).listen(Number(process.env.PORT ?? 8080));
        - lang: python
          label: Python
          source: |
            # Python 3.9+: receive and verify FoodShot webhooks with the standard library.
            # The ready-made `standardwebhooks` package on PyPI does the same check.
            import base64
            import hashlib
            import hmac
            import json
            import os
            import time
            from http.server import BaseHTTPRequestHandler, HTTPServer

            SECRET = os.environ["FOODSHOT_WEBHOOK_SECRET"]  # whsec_… of this environment
            KEY = base64.b64decode(SECRET.removeprefix("whsec_"))
            TOLERANCE_SECONDS = 5 * 60
            handled = set()  # keep handled event ids in your database instead


            def is_signed_by_foodshot(headers, raw_body: bytes) -> bool:
                msg_id = headers.get("webhook-id")
                timestamp = headers.get("webhook-timestamp")
                signatures = headers.get("webhook-signature")
                if not (msg_id and timestamp and signatures):
                    return False
                try:
                    if abs(time.time() - int(timestamp)) > TOLERANCE_SECONDS:
                        return False
                except ValueError:
                    return False
                signed = f"{msg_id}.{timestamp}.".encode() + raw_body
                expected = base64.b64encode(hmac.new(KEY, signed, hashlib.sha256).digest())
                # For 24 hours after a secret rotation there are two signatures, separated by a space.
                for entry in signatures.split(" "):
                    version, _, value = entry.partition(",")
                    if version == "v1" and hmac.compare_digest(value.encode(), expected):
                        return True
                return False


            class FoodShotWebhook(BaseHTTPRequestHandler):
                def do_POST(self):
                    raw_body = self.rfile.read(int(self.headers.get("Content-Length", "0")))
                    if not is_signed_by_foodshot(self.headers, raw_body):
                        self.send_response(400)
                        self.end_headers()
                        return
                    self.send_response(204)  # answer within 10 seconds, then do the work
                    self.end_headers()
                    event = json.loads(raw_body)
                    if event["id"] in handled:  # the same event can arrive more than once
                        return
                    handled.add(event["id"])
                    if event["type"] == "generation.succeeded":
                        pass  # download event["data"]["output"]["url"] from your server, then keep your own copy
                    elif event["type"] == "generation.failed":
                        pass  # event["data"]["error"]["code"] says why; the credit was refunded
                    # ignore event types you do not know


            HTTPServer(("", int(os.environ.get("PORT", "8080"))), FoodShotWebhook).serve_forever()
  generation.failed:
    post:
      operationId: generationFailedWebhook
      summary: generation.failed
      tags: [Webhooks]
      security: []
      description: |
        Sent when a generation fails. `data.error.code` says why, and the credit is refunded.
      parameters:
        - $ref: '#/components/parameters/WebhookId'
        - $ref: '#/components/parameters/WebhookTimestamp'
        - $ref: '#/components/parameters/WebhookSignature'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/GenerationFailedEvent'
            examples:
              content_refused:
                summary: A live generation failed with content_refused
                value:
                  id: evt_rYSdrrqV4cHBg9RlVFImuf
                  type: generation.failed
                  environment: live
                  created_at: '2026-10-18T10:40:31.208Z'
                  data:
                    id: gen_rYSdrrqV4cHBg9RlVFImuf
                    object: generation
                    environment: live
                    status: failed
                    created_at: '2026-10-18T10:40:02.117Z'
                    started_at: '2026-10-18T10:40:03.540Z'
                    completed_at: '2026-10-18T10:40:31.176Z'
                    style_id: white-hero
                    instruction: null
                    aspect_ratio: '4:5'
                    resolution: 4k
                    format: jpeg
                    metadata:
                      dish_id: d_457
                    credits_charged: 0
                    output: null
                    error:
                      code: content_refused
                      message: The photo or the instruction was refused by the content checks.
                    delivery:
                      status: pending
                      attempts: 0
                      last_status_code: null
                      last_attempt_at: null
      responses:
        '2XX':
          $ref: '#/components/responses/WebhookAccepted'
        default:
          $ref: '#/components/responses/WebhookRetried'
      x-codeSamples: *webhookReceiverSamples
  account.low_balance:
    post:
      operationId: accountLowBalanceWebhook
      summary: account.low_balance
      tags: [Webhooks]
      security: []
      description: |
        Sent when a job takes your live credits below your low-balance threshold, by default 20% of your last plan grant. It is sent again only after credits have been added.
      parameters:
        - $ref: '#/components/parameters/WebhookId'
        - $ref: '#/components/parameters/WebhookTimestamp'
        - $ref: '#/components/parameters/WebhookSignature'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/AccountLowBalanceEvent'
            examples:
              low_balance:
                summary: Credits fell below the threshold
                value:
                  id: evt_oCtt6VsHVKyRjFPgaJIBwK
                  type: account.low_balance
                  environment: live
                  created_at: '2026-10-25T14:03:11.452Z'
                  data:
                    object: account
                    id: acme-eats
                    environment: live
                    credits:
                      available: 199
                      low_balance: true
                      low_balance_threshold: 200
                      lots:
                        - id: lot_9iF8VdFw6H377trtTdF4OH
                          kind: plan
                          remaining: 199
                          granted: 1000
                          expires_at: '2026-11-01T00:00:00.000Z'
      responses:
        '2XX':
          $ref: '#/components/responses/WebhookAccepted'
        default:
          $ref: '#/components/responses/WebhookRetried'
      x-codeSamples: *webhookReceiverSamples
  webhook.test:
    post:
      operationId: webhookTestWebhook
      summary: webhook.test
      tags: [Webhooks]
      security: []
      description: |
        A test event, sent when you press "Send test event" in the dashboard.
      parameters:
        - $ref: '#/components/parameters/WebhookId'
        - $ref: '#/components/parameters/WebhookTimestamp'
        - $ref: '#/components/parameters/WebhookSignature'
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookTestEvent'
            examples:
              test_event:
                summary: A test event
                value:
                  id: evt_iBbnS3Z2WrxSId7M6F20KD
                  type: webhook.test
                  environment: test
                  created_at: '2026-10-18T09:12:30.104Z'
                  data:
                    object: webhook_test
                    message: This is a test event from FoodShot.
      responses:
        '2XX':
          $ref: '#/components/responses/WebhookAccepted'
        default:
          $ref: '#/components/responses/WebhookRetried'
      x-codeSamples: *webhookReceiverSamples
components:
  securitySchemes:
    ApiKey:
      type: http
      scheme: bearer
      bearerFormat: fs_live_… or fs_test_…
      description: |
        `Authorization: Bearer <key>`. A key is `fs_live_` or `fs_test_` followed by 43 letters and digits (`^fs_(live|test)_[0-9A-Za-z]{43}$`). Live keys work only from your server: sent from a browser, they get `403 permission_denied`.
  parameters:
    IdempotencyKey:
      name: Idempotency-Key
      in: header
      required: true
      description: |
        One unique value per photo, such as a UUID. Reuse it with the same body when you retry. Remembered for 24 hours.
      schema:
        type: string
        minLength: 1
        maxLength: 255
        pattern: '^[!-~]+$'
      example: 5f0c2b7e-9a4d-4c1b-8e3f-6d2a1b9c7e40
    GenerationId:
      name: id
      in: path
      required: true
      description: The generation id (`gen_…`).
      schema:
        type: string
        pattern: '^gen_[0-9A-Za-z]+$'
      example: gen_4Kq9xT2mVb7Zp1Ls8Hc3Rw
    ListStatus:
      name: status
      in: query
      required: false
      description: Only generations with this status.
      schema:
        $ref: '#/components/schemas/GenerationStatus'
    ListCreatedGte:
      name: created_gte
      in: query
      required: false
      description: Only generations created at or after this time (RFC 3339).
      schema:
        type: string
        format: date-time
      example: '2026-10-01T00:00:00Z'
    ListCreatedLt:
      name: created_lt
      in: query
      required: false
      description: Only generations created before this time (RFC 3339).
      schema:
        type: string
        format: date-time
      example: '2026-10-18T00:00:00Z'
    ListLimit:
      name: limit
      in: query
      required: false
      description: How many generations to return, 1 to 100.
      schema:
        type: integer
        minimum: 1
        maximum: 100
        default: 10
    ListStartingAfter:
      name: starting_after
      in: query
      required: false
      description: |
        The `id` of the last generation on the previous page.
      schema:
        type: string
        pattern: '^gen_[0-9A-Za-z]+$'
      example: gen_4Kq9xT2mVb7Zp1Ls8Hc3Rw
    StyleCategory:
      name: category
      in: query
      required: false
      description: |
        Only the styles of this category. See [Styles](/guides/styles#categories).
      schema:
        type: string
        enum: [delivery-apps, menu, fine-dining, lifestyle, bar-lounge, studio, pastry-bakery, rtd-beverage, packaged-food, spirits-bottles]
      example: delivery-apps
    FileGenerationId:
      name: generation_id
      in: path
      required: true
      description: Part of the signed link. Use `output.url` as given.
      schema:
        type: string
        pattern: '^gen_[0-9A-Za-z]+$'
      example: gen_4Kq9xT2mVb7Zp1Ls8Hc3Rw
    FileExtension:
      name: ext
      in: path
      required: true
      description: Part of the signed link. `jpg` for JPEG files, `png` for PNG files.
      schema:
        type: string
        enum: [jpg, png]
      example: jpg
    FileExpires:
      name: exp
      in: query
      required: true
      description: Part of the signed link. When the link stops working, in Unix seconds.
      schema:
        type: integer
      example: 1792406563
    FileSignature:
      name: sig
      in: query
      required: true
      description: Part of the signed link. Do not change it.
      schema:
        type: string
      example: 8fwqDeaHcFOU4b68TzesgCYKN8NG02QP3yMTmmVM9Pc
    WebhookId:
      name: webhook-id
      in: header
      required: true
      description: The event id. It stays the same on every retry, so use it to skip duplicates.
      schema:
        type: string
        pattern: '^evt_[0-9A-Za-z_]+$'
      example: evt_4Kq9xT2mVb7Zp1Ls8Hc3Rw
    WebhookTimestamp:
      name: webhook-timestamp
      in: header
      required: true
      description: When this attempt was signed, in Unix seconds. Reject requests more than 5 minutes from your clock.
      schema:
        type: string
        pattern: '^[0-9]+$'
      example: '1792320163'
    WebhookSignature:
      name: webhook-signature
      in: header
      required: true
      description: |
        `v1,` followed by the base64 HMAC-SHA256 of `{webhook-id}.{webhook-timestamp}.{raw body}`, keyed with the base64-decoded part of your secret after `whsec_`. For 24 hours after a secret rotation, two signatures are sent, separated by a space.
      schema:
        type: string
        pattern: '^v1,[A-Za-z0-9+/]+={0,2}( v1,[A-Za-z0-9+/]+={0,2})*$'
      example: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
  headers:
    RequestId:
      description: The id of this request. Include it when you contact us.
      required: true
      schema:
        type: string
        pattern: '^req_[0-9A-Za-z]+$'
      example: req_BTBBPZA0oirxnw23MU0t7E
    Location:
      description: The path of the new generation. Resolve it against `https://api.foodshot.ai`.
      required: true
      schema:
        type: string
      example: /v1/generations/gen_4Kq9xT2mVb7Zp1Ls8Hc3Rw
    IdempotentReplayed:
      description: '`true` when this answer repeats an earlier request with the same `Idempotency-Key` and body.'
      required: false
      schema:
        type: string
        const: 'true'
      example: 'true'
    RetryAfter:
      description: |
        How many seconds to wait before you retry.
      required: true
      schema:
        type: integer
        minimum: 1
      example: 2
    CacheControlPrivate:
      description: '`private`: only your own client may cache the file.'
      required: true
      schema:
        type: string
      example: private
  schemas:
    Environment:
      type: string
      enum: [live, test]
      description: '`live` or `test`, set by the key.'
    GenerationStatus:
      type: string
      enum: [queued, processing, succeeded, failed]
      description: |
        `queued`, `processing`, `succeeded` or `failed`. Only `succeeded` and `failed` are final.
    AspectRatio:
      type: string
      enum: ['1:1', '2:3', '3:2', '3:4', '4:3', '4:5', '5:4', '9:16', '16:9', '21:9']
      description: Width to height of the result.
    Resolution:
      type: string
      enum: [2k, 4k]
      description: The size of the result, `4k` by default. Both cost 1 credit.
    OutputFormat:
      type: string
      enum: [jpeg, png]
      description: The file type of the result, `jpeg` by default. The file is made and kept in this type.
    Metadata:
      type: object
      description: Up to 10 string pairs that you choose, returned with the generation. Keys and values up to 200 characters. Opaque ids only, never personal data.
      maxProperties: 10
      propertyNames:
        minLength: 1
        maxLength: 200
        not:
          pattern: '^__[\s\S]*__$'
      additionalProperties:
        type: string
        maxLength: 200
      examples:
        - dish_id: d_456
          vendor_id: v_1029
    CreateGenerationRequest:
      type: object
      description: |
        Send one of `image_url` or `image_base64`, and a `style_id`, an `instruction` or both.
      additionalProperties: false
      properties:
        image_url:
          type: string
          format: uri
          maxLength: 2048
          description: |
            An `https` URL of the photo, up to 2,048 characters. JPEG, PNG, WebP or HEIC, up to 20 MB. On a retry, send the same URL.
          examples:
            - https://docs.foodshot.ai/samples/dish.jpg
        image_base64:
          type: string
          contentEncoding: base64
          description: |
            The photo file in base64, if it has no URL. The whole request body can be up to 10 MB.
        style_id:
          type: string
          minLength: 1
          description: A style id from `GET /v1/styles`.
          examples:
            - pure-shot
        instruction:
          type: string
          minLength: 1
          maxLength: 10000
          description: A short note in plain words, such as "Keep the blue bowl". Up to 10,000 characters. Without a `style_id`, it describes the whole look.
          examples:
            - Keep the blue bowl
        aspect_ratio:
          $ref: '#/components/schemas/AspectRatio'
          default: '1:1'
        resolution:
          $ref: '#/components/schemas/Resolution'
          default: 4k
        format:
          $ref: '#/components/schemas/OutputFormat'
          default: jpeg
        metadata:
          $ref: '#/components/schemas/Metadata'
        test_scenario:
          type: string
          enum: [succeeded, content_refused, generation_failed, slow]
          default: succeeded
          description: |
            Test keys only. Picks the outcome of a test job; `slow` stays `processing` for about 2 minutes, then succeeds.
        webhook_url:
          type: string
          format: uri
          maxLength: 2048
          description: Test keys only. An `https` URL that gets this job's webhooks instead of your test endpoint.
          examples:
            - https://staging.example.com/foodshot/webhook
      # Cross-field rules, as JSON Schema conditions so that validators check them while
      # the docs renderer keeps one flat field table. The properties are defined above.
      allOf:
        # Exactly one of image_url and image_base64.
        - if:
            required: [image_url]
          then:
            not:
              required: [image_base64]
          else:
            required: [image_base64]
        # At least one of style_id and instruction.
        - if:
            not:
              required: [instruction]
          then:
            required: [style_id]
    Generation:
      type: object
      description: One photo turned into one result. Every endpoint and the `generation.*` webhooks return this object.
      required:
        - id
        - object
        - environment
        - status
        - created_at
        - started_at
        - completed_at
        - style_id
        - instruction
        - aspect_ratio
        - resolution
        - format
        - metadata
        - credits_charged
        - output
        - error
        - delivery
      properties:
        id:
          type: string
          pattern: '^gen_[0-9A-Za-z]+$'
          description: The generation id.
          examples:
            - gen_4Kq9xT2mVb7Zp1Ls8Hc3Rw
        object:
          type: string
          const: generation
        environment:
          $ref: '#/components/schemas/Environment'
        status:
          $ref: '#/components/schemas/GenerationStatus'
        created_at:
          type: string
          format: date-time
          description: When the job was accepted.
        started_at:
          type: [string, 'null']
          format: date-time
          description: When the job started, or `null`.
        completed_at:
          type: [string, 'null']
          format: date-time
          description: When the job succeeded or failed, or `null`.
        style_id:
          type: [string, 'null']
          description: The style you sent, or `null`.
        instruction:
          type: [string, 'null']
          description: The instruction you sent, or `null`.
        aspect_ratio:
          $ref: '#/components/schemas/AspectRatio'
        resolution:
          $ref: '#/components/schemas/Resolution'
        format:
          $ref: '#/components/schemas/OutputFormat'
        metadata:
          $ref: '#/components/schemas/Metadata'
        credits_charged:
          type: integer
          enum: [0, 1]
          description: '`1` for a live job, `0` once it failed (refunded) and always `0` for test keys.'
        output:
          description: The result once the job succeeded, otherwise `null`.
          oneOf:
            - $ref: '#/components/schemas/GenerationOutput'
            - type: 'null'
        error:
          description: Why the job failed, otherwise `null`.
          oneOf:
            - $ref: '#/components/schemas/GenerationError'
            - type: 'null'
        delivery:
          $ref: '#/components/schemas/WebhookDelivery'
    GenerationOutput:
      type: object
      description: |
        The result file. Live results carry an invisible AI watermark and an AI label in their metadata. Test keys return 2K SAMPLE images.
      required: [url, url_expires_at, available_until, content_type, width, height, bytes]
      properties:
        url:
          type: [string, 'null']
          format: uri
          description: A link to the file. It needs no key and works for 24 hours; every `GET` returns a fresh one. `null` once the file is deleted.
        url_expires_at:
          type: [string, 'null']
          format: date-time
          description: When this link stops working, or `null` once the file is deleted.
        available_until:
          type: string
          format: date-time
          description: When the file is deleted, 30 days after the job finished. Keep your own copy.
        content_type:
          type: string
          enum: [image/jpeg, image/png]
          description: 'The type of the file: `image/jpeg` for `format: jpeg`, `image/png` for `format: png`.'
        width:
          type: integer
          minimum: 1
          description: Width in pixels.
        height:
          type: integer
          minimum: 1
          description: Height in pixels.
        bytes:
          type: integer
          minimum: 1
          description: File size in bytes.
    GenerationError:
      type: object
      description: |
        Why a generation failed. Its credit is refunded.

        - `content_refused`: the photo or the instruction was refused.
        - `image_unprocessable`: no result could be made from this photo.
        - `generation_timeout`: it took too long. Retry with a new `Idempotency-Key`.
        - `generation_failed`: it failed for another reason. Retry with a new `Idempotency-Key`.
        - `queue_timeout`: it wasn't finished within 24 hours. Retry with a new `Idempotency-Key`.
      required: [code, message]
      properties:
        code:
          type: string
          description: |
            One of the codes above. More may be added: treat an unknown code like `generation_failed`.
          examples: [content_refused, image_unprocessable, generation_timeout, generation_failed, queue_timeout]
        message:
          type: string
          description: Fixed English text for developers. Don't parse it.
    WebhookDelivery:
      type: object
      description: |
        How the webhook for this generation is doing. If it `failed`, list your generations to catch up.
      required: [status, attempts, last_status_code, last_attempt_at]
      properties:
        status:
          type: string
          description: "`none` (nothing to send yet, or no endpoint), `pending`, `delivered` or `failed`. More may be added."
          examples: [none, pending, delivered, failed]
        attempts:
          type: integer
          minimum: 0
          description: Attempts so far.
        last_status_code:
          type: [integer, 'null']
          minimum: 100
          maximum: 599
          description: The HTTP status of the last attempt, or `null`.
        last_attempt_at:
          type: [string, 'null']
          format: date-time
          description: When the last attempt started, or `null`.
    GenerationList:
      type: object
      required: [object, data, has_more]
      properties:
        object:
          type: string
          const: list
        data:
          type: array
          items:
            $ref: '#/components/schemas/Generation'
        has_more:
          type: boolean
          description: '`true` if there are older generations. Pass the last `id` as `starting_after` to get them.'
    Style:
      type: object
      required:
        - id
        - object
        - name
        - category
        - category_name
        - description
        - angle
        - recommended_aspect_ratios
        - thumbnail_url
        - example
        - retires_at
      properties:
        id:
          type: string
          description: The value for `style_id`. It never changes.
          examples:
            - pure-shot
        object:
          type: string
          const: style
        name:
          type: string
          description: The English display name.
          examples:
            - Pure Shot
        category:
          type: string
          description: The category id, such as `delivery-apps`. More categories may be added.
          examples: [delivery-apps, menu, fine-dining, lifestyle, bar-lounge, studio, pastry-bakery, rtd-beverage, packaged-food, spirits-bottles]
        category_name:
          type: string
          description: The category's English display name.
          examples:
            - Delivery & Takeout
        description:
          type: [string, 'null']
          description: One or two sentences about the look, or `null`.
        angle:
          type: [string, 'null']
          description: 'How to take the photo: `top_down` (from above), `angled` (about 45°) or `any`. Can be `null`; then follow the thumbnail.'
          examples: [top_down, angled, any]
        recommended_aspect_ratios:
          type: [array, 'null']
          description: The ratios that suit this style best, or `null`. Every ratio works.
          items:
            $ref: '#/components/schemas/AspectRatio'
          examples:
            - ['1:1', '4:5']
        thumbnail_url:
          type: string
          format: uri
          description: A preview image of the style. Use the URL as given.
        example:
          description: A before and after of one of FoodShot's own test dishes in this style, or `null`.
          oneOf:
            - $ref: '#/components/schemas/StyleExample'
            - type: 'null'
        retires_at:
          type: [string, 'null']
          format: date-time
          description: When the style will be retired, set at least 30 days ahead, or `null`.
    StyleExample:
      type: object
      required: [before_url, after_url]
      properties:
        before_url:
          type: string
          format: uri
        after_url:
          type: string
          format: uri
    StyleList:
      type: object
      required: [object, data, has_more]
      properties:
        object:
          type: string
          const: list
        data:
          type: array
          items:
            $ref: '#/components/schemas/Style'
        has_more:
          type: boolean
          const: false
          description: Always `false`. The list is not paginated.
    Account:
      type: object
      required: [object, id, environment, credits, limits, usage, key]
      properties:
        object:
          type: string
          const: account
        id:
          type: string
          description: Your account id.
          examples:
            - acme-eats
        environment:
          $ref: '#/components/schemas/Environment'
        credits:
          description: Live credits; `null` for test keys.
          oneOf:
            - $ref: '#/components/schemas/AccountCredits'
            - type: 'null'
        limits:
          $ref: '#/components/schemas/AccountLimits'
        usage:
          $ref: '#/components/schemas/AccountUsage'
        key:
          $ref: '#/components/schemas/AccountKey'
    AccountCredits:
      type: object
      description: |
        1 credit = 1 finished image (any style, ratio, 2K or 4K); failed jobs are free; prepaid; added within 1 business day of payment.
      required: [available, low_balance, low_balance_threshold, lots]
      properties:
        available:
          type: integer
          minimum: 0
          description: Credits you can use now.
        low_balance:
          type: boolean
          description: '`true` while `available` is below `low_balance_threshold`.'
        low_balance_threshold:
          type: [integer, 'null']
          minimum: 0
          description: By default 20% of your last plan grant; `null` before your first plan.
        lots:
          type: array
          description: Your credit lots, in the order they are used.
          items:
            $ref: '#/components/schemas/CreditLot'
    CreditLot:
      type: object
      required: [id, kind, remaining, granted, expires_at]
      properties:
        id:
          type: string
          pattern: '^lot_[0-9A-Za-z]+$'
        kind:
          type: string
          description: |
            `trial` (ends 30 days after it was added), `plan` (ends with its period), `pack` or `adjust`. More may be added, so read `expires_at`.
          examples: [trial, plan, pack, adjust]
        remaining:
          type: integer
          minimum: 0
        granted:
          type: integer
          minimum: 0
        expires_at:
          type: [string, 'null']
          format: date-time
          description: When the lot ends, or `null` if it never does.
    AccountLimits:
      type: object
      description: Your limits in this environment.
      required: [parallel_jobs, queued_jobs, jobs_per_day, day_resets_at, new_jobs_per_minute, requests_per_minute]
      properties:
        parallel_jobs:
          type: integer
          description: Jobs that run at the same time.
          examples:
            - 5
        queued_jobs:
          type: integer
          description: Jobs that can wait in your queue.
          examples:
            - 500
        jobs_per_day:
          type: integer
          description: Jobs accepted per UTC day.
          examples:
            - 300
        day_resets_at:
          type: string
          description: When the daily count resets, in UTC.
          examples:
            - '00:00Z'
        new_jobs_per_minute:
          type: integer
          description: New generations per minute, per key.
          examples:
            - 60
        requests_per_minute:
          type: integer
          description: Other requests per minute, per key.
          examples:
            - 300
    AccountUsage:
      type: object
      description: Job counts for today and this month, in UTC.
      required: [today, month]
      properties:
        today:
          $ref: '#/components/schemas/UsageCounts'
        month:
          $ref: '#/components/schemas/UsageCounts'
    UsageCounts:
      type: object
      required: [accepted, succeeded, failed]
      properties:
        accepted:
          type: integer
          minimum: 0
          description: Jobs accepted.
        succeeded:
          type: integer
          minimum: 0
          description: Jobs that succeeded.
        failed:
          type: integer
          minimum: 0
          description: Jobs that failed and were refunded.
    AccountKey:
      type: object
      description: The key used for this request.
      required: [id, prefix]
      properties:
        id:
          type: string
          pattern: '^key_[0-9A-Za-z]+$'
        prefix:
          type: string
          pattern: '^fs_(live|test)_[0-9A-Za-z]{4}$'
          description: The first characters of the key.
    Health:
      type: object
      required: [status]
      properties:
        status:
          type: string
          const: ok
    Error:
      type: object
      description: The error envelope. No error costs credits.
      required: [error]
      properties:
        error:
          $ref: '#/components/schemas/ErrorDetail'
    ErrorDetail:
      type: object
      required: [code, message, param, request_id]
      properties:
        code:
          type: string
          description: |
            A stable error code. See [Errors](/guides/errors). More may be added: handle an unknown code by its HTTP status.
          examples:
            - invalid_request
            - style_not_available
            - image_unreadable
            - image_too_small
            - image_fetch_failed
            - invalid_api_key
            - insufficient_credits
            - permission_denied
            - account_suspended
            - not_found
            - idempotency_conflict
            - image_too_large
            - payload_too_large
            - unsupported_media_type
            - rate_limited
            - queue_full
            - daily_limit_reached
            - busy
            - internal_error
            - service_unavailable
        message:
          type: string
          description: Fixed English text for developers. Don't parse it.
        param:
          type: [string, 'null']
          description: The field, parameter or header at fault, or `null`.
        request_id:
          type: string
          pattern: '^req_[0-9A-Za-z]+$'
          description: The same as the `FoodShot-Request-Id` header.
        generation_id:
          type: string
          pattern: '^gen_[0-9A-Za-z]+$'
          description: Only with `idempotency_conflict`. The generation made earlier with this key.
    EventBase:
      type: object
      required: [id, type, environment, created_at, data]
      properties:
        id:
          type: string
          pattern: '^evt_[0-9A-Za-z_]+$'
          description: The event id, the same as the `webhook-id` header.
        type:
          type: string
          description: The event type. Ignore types you don't know.
        environment:
          $ref: '#/components/schemas/Environment'
        created_at:
          type: string
          format: date-time
          description: When the event happened.
        data:
          description: The event's object.
    GenerationSucceededEvent:
      allOf:
        - $ref: '#/components/schemas/EventBase'
        - type: object
          properties:
            type:
              const: generation.succeeded
            data:
              $ref: '#/components/schemas/Generation'
    GenerationFailedEvent:
      allOf:
        - $ref: '#/components/schemas/EventBase'
        - type: object
          properties:
            type:
              const: generation.failed
            data:
              $ref: '#/components/schemas/Generation'
    AccountLowBalanceEvent:
      allOf:
        - $ref: '#/components/schemas/EventBase'
        - type: object
          properties:
            type:
              const: account.low_balance
            environment:
              const: live
            data:
              $ref: '#/components/schemas/LowBalanceData'
    WebhookTestEvent:
      allOf:
        - $ref: '#/components/schemas/EventBase'
        - type: object
          properties:
            type:
              const: webhook.test
            data:
              $ref: '#/components/schemas/WebhookTestData'
    LowBalanceData:
      type: object
      required: [object, id, environment, credits]
      properties:
        object:
          type: string
          const: account
        id:
          type: string
          description: Your account id.
        environment:
          type: string
          const: live
        credits:
          $ref: '#/components/schemas/AccountCredits'
    WebhookTestData:
      type: object
      required: [object, message]
      properties:
        object:
          type: string
          const: webhook_test
        message:
          type: string
  responses:
    BadRequestCreate:
      description: |
        The request can't be accepted as sent. Nothing was charged.
      headers:
        FoodShot-Request-Id:
          $ref: '#/components/headers/RequestId'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            idempotency_key_missing:
              $ref: '#/components/examples/ErrIdempotencyKeyMissing'
            idempotency_key_invalid:
              $ref: '#/components/examples/ErrIdempotencyKeyInvalid'
            body_not_object:
              $ref: '#/components/examples/ErrBodyNotObject'
            field_invalid:
              $ref: '#/components/examples/ErrFieldInvalid'
            field_unknown:
              $ref: '#/components/examples/ErrFieldUnknown'
            field_test_only:
              $ref: '#/components/examples/ErrFieldTestOnly'
            image_source:
              $ref: '#/components/examples/ErrImageSource'
            style_or_instruction:
              $ref: '#/components/examples/ErrStyleOrInstruction'
            style_not_available:
              $ref: '#/components/examples/ErrStyleNotAvailable'
            image_unreadable:
              $ref: '#/components/examples/ErrImageUnreadable'
            image_too_small:
              $ref: '#/components/examples/ErrImageTooSmall'
            image_fetch_failed:
              $ref: '#/components/examples/ErrImageFetchFailed'
            query_unknown:
              $ref: '#/components/examples/ErrQueryUnknown'
    BadRequestQuery:
      description: A query parameter is wrong or unknown.
      headers:
        FoodShot-Request-Id:
          $ref: '#/components/headers/RequestId'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            query_invalid:
              $ref: '#/components/examples/ErrQueryInvalid'
            query_unknown:
              $ref: '#/components/examples/ErrQueryUnknown'
    BadRequestStyles:
      description: The `category` or another query parameter is wrong.
      headers:
        FoodShot-Request-Id:
          $ref: '#/components/headers/RequestId'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            category_invalid:
              $ref: '#/components/examples/ErrCategoryInvalid'
            query_unknown:
              $ref: '#/components/examples/ErrQueryUnknown'
    Unauthorized:
      description: The key is missing, wrong or revoked.
      headers:
        FoodShot-Request-Id:
          $ref: '#/components/headers/RequestId'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            invalid_api_key:
              $ref: '#/components/examples/ErrInvalidApiKey'
    PaymentRequired:
      description: No live credits are left. Nothing was charged.
      headers:
        FoodShot-Request-Id:
          $ref: '#/components/headers/RequestId'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            insufficient_credits:
              $ref: '#/components/examples/ErrInsufficientCredits'
    Forbidden:
      description: This key may not make this request.
      headers:
        FoodShot-Request-Id:
          $ref: '#/components/headers/RequestId'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            live_key_from_browser:
              $ref: '#/components/examples/ErrLiveKeyFromBrowser'
            environment_not_enabled:
              $ref: '#/components/examples/ErrEnvironmentNotEnabled'
            account_suspended:
              $ref: '#/components/examples/ErrAccountSuspended'
    ForbiddenFile:
      description: The link is wrong or has expired. Get a fresh `output.url`.
      headers:
        FoodShot-Request-Id:
          $ref: '#/components/headers/RequestId'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            file_link_invalid:
              $ref: '#/components/examples/ErrFileLinkInvalid'
    NotFoundGeneration:
      description: No such generation for this key in the last 90 days.
      headers:
        FoodShot-Request-Id:
          $ref: '#/components/headers/RequestId'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            generation_not_found:
              $ref: '#/components/examples/ErrGenerationNotFound'
            path_not_found:
              $ref: '#/components/examples/ErrPathNotFound'
    NotFoundFile:
      description: The file has been deleted or doesn't exist.
      headers:
        FoodShot-Request-Id:
          $ref: '#/components/headers/RequestId'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            file_gone:
              $ref: '#/components/examples/ErrFileGone'
    Conflict:
      description: This `Idempotency-Key` was used with a different body in the last 24 hours.
      headers:
        FoodShot-Request-Id:
          $ref: '#/components/headers/RequestId'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            idempotency_conflict:
              $ref: '#/components/examples/ErrIdempotencyConflict'
    PayloadTooLarge:
      description: The photo or the request body is too large.
      headers:
        FoodShot-Request-Id:
          $ref: '#/components/headers/RequestId'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            image_too_large:
              $ref: '#/components/examples/ErrImageTooLarge'
            payload_too_large:
              $ref: '#/components/examples/ErrPayloadTooLarge'
    UnsupportedMediaType:
      description: The body isn't JSON, or the photo isn't JPEG, PNG, WebP or HEIC.
      headers:
        FoodShot-Request-Id:
          $ref: '#/components/headers/RequestId'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            body_not_json:
              $ref: '#/components/examples/ErrBodyNotJson'
            image_format:
              $ref: '#/components/examples/ErrImageFormat'
    TooManyRequestsCreate:
      description: Over a limit, or busy. Retry after `Retry-After` seconds with the same `Idempotency-Key` and body.
      headers:
        FoodShot-Request-Id:
          $ref: '#/components/headers/RequestId'
        Retry-After:
          $ref: '#/components/headers/RetryAfter'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            rate_limited:
              $ref: '#/components/examples/ErrRateLimited'
            queue_full:
              $ref: '#/components/examples/ErrQueueFull'
            daily_limit_reached:
              $ref: '#/components/examples/ErrDailyLimitReached'
            busy:
              $ref: '#/components/examples/ErrBusy'
    TooManyRequestsRead:
      description: Over the request limit of this key. Retry after `Retry-After` seconds.
      headers:
        FoodShot-Request-Id:
          $ref: '#/components/headers/RequestId'
        Retry-After:
          $ref: '#/components/headers/RetryAfter'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            rate_limited:
              $ref: '#/components/examples/ErrRateLimited'
    InternalError:
      description: Something went wrong on our side. Retry; for a `POST`, with the same `Idempotency-Key` and body.
      headers:
        FoodShot-Request-Id:
          $ref: '#/components/headers/RequestId'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            internal_error:
              $ref: '#/components/examples/ErrInternal'
    ServiceUnavailableCreate:
      description: We can't accept new jobs right now. Retry after `Retry-After` seconds with the same `Idempotency-Key` and body.
      headers:
        FoodShot-Request-Id:
          $ref: '#/components/headers/RequestId'
        Retry-After:
          $ref: '#/components/headers/RetryAfter'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            service_unavailable:
              $ref: '#/components/examples/ErrServiceUnavailable'
            not_accepting_jobs:
              $ref: '#/components/examples/ErrNotAcceptingJobs'
            opens_by_invitation:
              $ref: '#/components/examples/ErrOpensByInvitation'
    ServiceUnavailable:
      description: Temporarily unavailable. Retry after `Retry-After` seconds.
      headers:
        FoodShot-Request-Id:
          $ref: '#/components/headers/RequestId'
        Retry-After:
          $ref: '#/components/headers/RetryAfter'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            service_unavailable:
              $ref: '#/components/examples/ErrServiceUnavailable'
    ServiceUnavailableRead:
      description: Temporarily unavailable. Retry after `Retry-After` seconds.
      headers:
        FoodShot-Request-Id:
          $ref: '#/components/headers/RequestId'
        Retry-After:
          $ref: '#/components/headers/RetryAfter'
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/Error'
          examples:
            service_unavailable:
              $ref: '#/components/examples/ErrServiceUnavailable'
            opens_by_invitation:
              $ref: '#/components/examples/ErrOpensByInvitation'
    WebhookAccepted:
      description: Any `2xx` within 10 seconds marks the event as delivered.
    WebhookRetried:
      description: Anything else, or no answer within 10 seconds, is a failed attempt. We retry for about 24 hours.
  examples:
    GenerationQueued:
      summary: A new live generation, queued
      value:
        id: gen_4Kq9xT2mVb7Zp1Ls8Hc3Rw
        object: generation
        environment: live
        status: queued
        created_at: '2026-10-18T10:42:07.512Z'
        started_at: null
        completed_at: null
        style_id: pure-shot
        instruction: null
        aspect_ratio: '1:1'
        resolution: 4k
        format: jpeg
        metadata:
          dish_id: d_456
          vendor_id: v_1029
        credits_charged: 1
        output: null
        error: null
        delivery:
          status: none
          attempts: 0
          last_status_code: null
          last_attempt_at: null
    GenerationQueuedInstruction2k:
      summary: An instruction-only request at 2K, queued
      value:
        id: gen_7HtP2wQx9LmZc4Vb1Nr8Ks
        object: generation
        environment: live
        status: queued
        created_at: '2026-10-18T10:44:31.207Z'
        started_at: null
        completed_at: null
        style_id: null
        instruction: Bright daylight on a light wooden table, seen from above
        aspect_ratio: '16:9'
        resolution: 2k
        format: jpeg
        metadata:
          dish_id: d_458
        credits_charged: 1
        output: null
        error: null
        delivery:
          status: none
          attempts: 0
          last_status_code: null
          last_attempt_at: null
    GenerationProcessing:
      summary: A live generation, processing
      value:
        id: gen_4Kq9xT2mVb7Zp1Ls8Hc3Rw
        object: generation
        environment: live
        status: processing
        created_at: '2026-10-18T10:42:07.512Z'
        started_at: '2026-10-18T10:42:09.031Z'
        completed_at: null
        style_id: pure-shot
        instruction: null
        aspect_ratio: '1:1'
        resolution: 4k
        format: jpeg
        metadata:
          dish_id: d_456
          vendor_id: v_1029
        credits_charged: 1
        output: null
        error: null
        delivery:
          status: none
          attempts: 0
          last_status_code: null
          last_attempt_at: null
    GenerationSucceeded:
      summary: A live generation, succeeded
      value:
        id: gen_4Kq9xT2mVb7Zp1Ls8Hc3Rw
        object: generation
        environment: live
        status: succeeded
        created_at: '2026-10-18T10:42:07.512Z'
        started_at: '2026-10-18T10:42:09.031Z'
        completed_at: '2026-10-18T10:42:42.870Z'
        style_id: pure-shot
        instruction: null
        aspect_ratio: '1:1'
        resolution: 4k
        format: jpeg
        metadata:
          dish_id: d_456
          vendor_id: v_1029
        credits_charged: 1
        output:
          url: https://api.foodshot.ai/v1/files/gen_4Kq9xT2mVb7Zp1Ls8Hc3Rw/image.jpg?exp=1792406595&sig=VT5DMYyFzJsUzoKdU9fob--vTZ1Nx5PFShO290vXCjc
          url_expires_at: '2026-10-19T10:43:15.000Z'
          available_until: '2026-11-17T10:42:42.870Z'
          content_type: image/jpeg
          width: 4096
          height: 4096
          bytes: 8734516
        error: null
        delivery:
          status: delivered
          attempts: 1
          last_status_code: 200
          last_attempt_at: '2026-10-18T10:42:43.402Z'
    GenerationFailed:
      summary: A live generation, failed and refunded
      value:
        id: gen_rYSdrrqV4cHBg9RlVFImuf
        object: generation
        environment: live
        status: failed
        created_at: '2026-10-18T10:40:02.117Z'
        started_at: '2026-10-18T10:40:03.540Z'
        completed_at: '2026-10-18T10:40:31.176Z'
        style_id: white-hero
        instruction: null
        aspect_ratio: '4:5'
        resolution: 4k
        format: jpeg
        metadata:
          dish_id: d_457
        credits_charged: 0
        output: null
        error:
          code: content_refused
          message: The photo or the instruction was refused by the content checks.
        delivery:
          status: delivered
          attempts: 1
          last_status_code: 200
          last_attempt_at: '2026-10-18T10:40:31.655Z'
    GenerationTestSucceeded:
      summary: A test-key generation, succeeded with a SAMPLE image (2K, whatever the resolution)
      value:
        id: gen_zQvnlJMZk4LVKPKmJRYYou
        object: generation
        environment: test
        status: succeeded
        created_at: '2026-10-18T09:15:20.006Z'
        started_at: '2026-10-18T09:15:20.884Z'
        completed_at: '2026-10-18T09:15:23.391Z'
        style_id: pure-shot
        instruction: null
        aspect_ratio: '1:1'
        resolution: 4k
        format: jpeg
        metadata:
          dish_id: d_456
        credits_charged: 0
        output:
          url: https://api.foodshot.ai/v1/files/gen_zQvnlJMZk4LVKPKmJRYYou/image.jpg?exp=1792401330&sig=hWnD8NuE-HB-GoEf6R4kJlNj210G-W3hybBD1g3qc-A
          url_expires_at: '2026-10-19T09:15:30.000Z'
          available_until: '2026-11-17T09:15:23.391Z'
          content_type: image/jpeg
          width: 2048
          height: 2048
          bytes: 1187322
        error: null
        delivery:
          status: delivered
          attempts: 1
          last_status_code: 204
          last_attempt_at: '2026-10-18T09:15:23.702Z'
    GenerationListPage:
      summary: A page of two generations, with more to come
      value:
        object: list
        data:
          - id: gen_4Kq9xT2mVb7Zp1Ls8Hc3Rw
            object: generation
            environment: live
            status: succeeded
            created_at: '2026-10-18T10:42:07.512Z'
            started_at: '2026-10-18T10:42:09.031Z'
            completed_at: '2026-10-18T10:42:42.870Z'
            style_id: pure-shot
            instruction: null
            aspect_ratio: '1:1'
            resolution: 4k
            format: jpeg
            metadata:
              dish_id: d_456
              vendor_id: v_1029
            credits_charged: 1
            output:
              url: https://api.foodshot.ai/v1/files/gen_4Kq9xT2mVb7Zp1Ls8Hc3Rw/image.jpg?exp=1792406595&sig=VT5DMYyFzJsUzoKdU9fob--vTZ1Nx5PFShO290vXCjc
              url_expires_at: '2026-10-19T10:43:15.000Z'
              available_until: '2026-11-17T10:42:42.870Z'
              content_type: image/jpeg
              width: 4096
              height: 4096
              bytes: 8734516
            error: null
            delivery:
              status: delivered
              attempts: 1
              last_status_code: 200
              last_attempt_at: '2026-10-18T10:42:43.402Z'
          - id: gen_rYSdrrqV4cHBg9RlVFImuf
            object: generation
            environment: live
            status: failed
            created_at: '2026-10-18T10:40:02.117Z'
            started_at: '2026-10-18T10:40:03.540Z'
            completed_at: '2026-10-18T10:40:31.176Z'
            style_id: white-hero
            instruction: null
            aspect_ratio: '4:5'
            resolution: 4k
            format: jpeg
            metadata:
              dish_id: d_457
            credits_charged: 0
            output: null
            error:
              code: content_refused
              message: The photo or the instruction was refused by the content checks.
            delivery:
              status: delivered
              attempts: 1
              last_status_code: 200
              last_attempt_at: '2026-10-18T10:40:31.655Z'
        has_more: true
    StyleListExample:
      summary: Two styles, with description, angle and recommended_aspect_ratios null
      value:
        object: list
        data:
          - id: pure-shot
            object: style
            name: Pure Shot
            category: delivery-apps
            category_name: Delivery & Takeout
            description: null
            angle: null
            recommended_aspect_ratios: null
            thumbnail_url: https://cdn.foodshot.ai/style-thumbnails/2026-09-24/pure-shot_0123456789ab.webp
            example: null
            retires_at: null
          - id: white-hero
            object: style
            name: White Hero
            category: delivery-apps
            category_name: Delivery & Takeout
            description: null
            angle: null
            recommended_aspect_ratios: null
            thumbnail_url: https://cdn.foodshot.ai/style-thumbnails/2026-09-24/white-hero_fedcba987654.webp
            example: null
            retires_at: null
        has_more: false
    AccountLive:
      summary: A live key
      value:
        object: account
        id: acme-eats
        environment: live
        credits:
          available: 712
          low_balance: false
          low_balance_threshold: 200
          lots:
            - id: lot_9iF8VdFw6H377trtTdF4OH
              kind: plan
              remaining: 712
              granted: 1000
              expires_at: '2026-11-01T00:00:00.000Z'
        limits:
          parallel_jobs: 5
          queued_jobs: 500
          jobs_per_day: 300
          day_resets_at: '00:00Z'
          new_jobs_per_minute: 60
          requests_per_minute: 300
        usage:
          today:
            accepted: 37
            succeeded: 30
            failed: 1
          month:
            accepted: 812
            succeeded: 790
            failed: 9
        key:
          id: key_Qbv9XAAWzwlrKrWrmOePgG
          prefix: fs_live_AbC4
    AccountTest:
      summary: A test key
      value:
        object: account
        id: acme-eats
        environment: test
        credits: null
        limits:
          parallel_jobs: 5
          queued_jobs: 500
          jobs_per_day: 1000
          day_resets_at: '00:00Z'
          new_jobs_per_minute: 60
          requests_per_minute: 300
        usage:
          today:
            accepted: 12
            succeeded: 11
            failed: 1
          month:
            accepted: 240
            succeeded: 231
            failed: 9
        key:
          id: key_w0FW4wOq3Iflzz4twBTBzK
          prefix: fs_test_GQAA
    ErrIdempotencyKeyMissing:
      summary: invalid_request, Idempotency-Key missing
      description: "No Idempotency-Key header was sent."
      value:
        error:
          code: invalid_request
          message: The Idempotency-Key header is required. Send one unique value per photo, such as a UUID, and reuse it when you retry.
          param: Idempotency-Key
          request_id: req_daJCWaeLcd6uXjfMaJeplG
    ErrIdempotencyKeyInvalid:
      summary: invalid_request, Idempotency-Key invalid
      description: "The Idempotency-Key is empty, too long or not visible ASCII."
      value:
        error:
          code: invalid_request
          message: The Idempotency-Key header must be 1 to 255 visible ASCII characters.
          param: Idempotency-Key
          request_id: req_daJCWaeLcd6uXjfMaJeplG
    ErrBodyNotObject:
      summary: invalid_request, body is not a JSON object
      description: "The body isn't a JSON object."
      value:
        error:
          code: invalid_request
          message: The request body must be a JSON object.
          param: null
          request_id: req_daJCWaeLcd6uXjfMaJeplG
    ErrFieldInvalid:
      summary: invalid_request, a field is invalid
      description: "A field has a wrong type or value."
      value:
        error:
          code: invalid_request
          message: The field named in param is missing or invalid.
          param: aspect_ratio
          request_id: req_daJCWaeLcd6uXjfMaJeplG
    ErrFieldUnknown:
      summary: invalid_request, an unknown field
      description: "The body has a field the API doesn't know."
      value:
        error:
          code: invalid_request
          message: The field named in param is not part of the API.
          param: prompt
          request_id: req_daJCWaeLcd6uXjfMaJeplG
    ErrFieldTestOnly:
      summary: invalid_request, a test-only field with a live key
      description: "test_scenario or webhook_url was sent with a live key."
      value:
        error:
          code: invalid_request
          message: The field named in param works only with test keys.
          param: test_scenario
          request_id: req_daJCWaeLcd6uXjfMaJeplG
    ErrImageSource:
      summary: invalid_request, both or neither photo fields
      description: "Both image_url and image_base64 were sent, or neither."
      value:
        error:
          code: invalid_request
          message: Send exactly one of image_url and image_base64.
          param: image_url
          request_id: req_daJCWaeLcd6uXjfMaJeplG
    ErrStyleOrInstruction:
      summary: invalid_request, no style and no instruction
      description: "Neither style_id nor instruction was sent."
      value:
        error:
          code: invalid_request
          message: Send style_id, instruction, or both.
          param: style_id
          request_id: req_daJCWaeLcd6uXjfMaJeplG
    ErrStyleNotAvailable:
      summary: style_not_available
      description: "The style_id is unknown or retired."
      value:
        error:
          code: style_not_available
          message: This style is not available to your account. Choose one from GET /v1/styles.
          param: style_id
          request_id: req_daJCWaeLcd6uXjfMaJeplG
    ErrImageUnreadable:
      summary: image_unreadable
      description: "The photo file is damaged and can't be opened."
      value:
        error:
          code: image_unreadable
          message: The photo could not be read. It may be damaged or incomplete.
          param: image_url
          request_id: req_8d4DxP5n53EgewaJFOqGyS
    ErrImageTooSmall:
      summary: image_too_small
      description: "The photo is under 512 px on its short side."
      value:
        error:
          code: image_too_small
          message: The photo is too small. Its short side must be at least 512 pixels; 1,024 or more works best.
          param: image_base64
          request_id: req_8d4DxP5n53EgewaJFOqGyS
    ErrImageFetchFailed:
      summary: image_fetch_failed
      description: "We couldn't download image_url. Retry once it works, with the same key and body."
      value:
        error:
          code: image_fetch_failed
          message: The photo could not be downloaded from image_url.
          param: image_url
          request_id: req_8d4DxP5n53EgewaJFOqGyS
    ErrInvalidApiKey:
      summary: invalid_api_key
      description: "The key is missing, wrong or revoked."
      value:
        error:
          code: invalid_api_key
          message: The API key is missing, malformed, unknown or revoked.
          param: null
          request_id: req_NPfzXU77n1f2QYM559fGL8
    ErrInsufficientCredits:
      summary: insufficient_credits
      description: "No live credits are left."
      value:
        error:
          code: insufficient_credits
          message: Your account has no credits left for live jobs.
          param: null
          request_id: req_NPfzXU77n1f2QYM559fGL8
    ErrLiveKeyFromBrowser:
      summary: permission_denied, live key from a browser
      description: "A live key was sent from a browser. Call the API from your server."
      value:
        error:
          code: permission_denied
          message: Live keys work only from your server.
          param: null
          request_id: req_NPfzXU77n1f2QYM559fGL8
    ErrEnvironmentNotEnabled:
      summary: permission_denied, environment not enabled
      description: "This environment isn't open for your account, for example live before live access."
      value:
        error:
          code: permission_denied
          message: This key's environment is not enabled for your account.
          param: null
          request_id: req_NPfzXU77n1f2QYM559fGL8
    ErrAccountSuspended:
      summary: account_suspended
      description: "Your account is suspended. Contact us."
      value:
        error:
          code: account_suspended
          message: This account is suspended. Contact hello@foodshot.ai.
          param: null
          request_id: req_NPfzXU77n1f2QYM559fGL8
    ErrGenerationNotFound:
      summary: not_found, generation
      description: "No generation with this id for this key in the last 90 days."
      value:
        error:
          code: not_found
          message: No generation with this id in this environment.
          param: id
          request_id: req_UTyanAcKR2uQdufRHA9T5L
    ErrFileGone:
      summary: not_found, file
      description: "The file has been deleted or doesn't exist."
      value:
        error:
          code: not_found
          message: This file is no longer available.
          param: null
          request_id: req_UTyanAcKR2uQdufRHA9T5L
    ErrPathNotFound:
      summary: not_found, unknown path or method
      description: "No endpoint has this method and path."
      value:
        error:
          code: not_found
          message: This path or method is not part of the API.
          param: null
          request_id: req_UTyanAcKR2uQdufRHA9T5L
    ErrFileLinkInvalid:
      summary: permission_denied, file link invalid or expired
      description: "The link is wrong or has expired. Get a fresh output.url."
      value:
        error:
          code: permission_denied
          message: This file link is invalid or has expired. Get a fresh link with GET /v1/generations/{id}.
          param: null
          request_id: req_UTyanAcKR2uQdufRHA9T5L
    ErrIdempotencyConflict:
      summary: idempotency_conflict
      description: "The Idempotency-Key was used with a different body in the last 24 hours."
      value:
        error:
          code: idempotency_conflict
          message: This Idempotency-Key was used in the last 24 hours with a different request body. Resend the identical body, or use a new key for a new photo.
          param: Idempotency-Key
          request_id: req_UTyanAcKR2uQdufRHA9T5L
          generation_id: gen_4Kq9xT2mVb7Zp1Ls8Hc3Rw
    ErrImageTooLarge:
      summary: image_too_large
      description: "The photo is over 20 MB or 100 megapixels."
      value:
        error:
          code: image_too_large
          message: The photo is too large. Files can be up to 20 MB and 100 megapixels.
          param: image_url
          request_id: req_utTipsOfv9dKW2NR0nfFye
    ErrPayloadTooLarge:
      summary: payload_too_large
      description: "The body is over 10 MB. Send image_url instead."
      value:
        error:
          code: payload_too_large
          message: The request body is too large. The limit is 10 MB; send larger photos with image_url.
          param: null
          request_id: req_utTipsOfv9dKW2NR0nfFye
    ErrBodyNotJson:
      summary: unsupported_media_type, body
      description: "The body wasn't sent as application/json."
      value:
        error:
          code: unsupported_media_type
          message: Send the request body as application/json.
          param: null
          request_id: req_utTipsOfv9dKW2NR0nfFye
    ErrImageFormat:
      summary: unsupported_media_type, photo
      description: "The photo isn't JPEG, PNG, WebP or HEIC."
      value:
        error:
          code: unsupported_media_type
          message: The photo must be JPEG, PNG, WebP or HEIC.
          param: image_url
          request_id: req_utTipsOfv9dKW2NR0nfFye
    ErrRateLimited:
      summary: rate_limited
      description: "Too many requests for this key. Retry after Retry-After seconds."
      value:
        error:
          code: rate_limited
          message: Too many requests. Retry after the number of seconds in Retry-After.
          param: null
          request_id: req_BTBBPZA0oirxnw23MU0t7E
    ErrQueueFull:
      summary: queue_full
      description: "500 jobs are already waiting. Retry after Retry-After seconds."
      value:
        error:
          code: queue_full
          message: Your queue of waiting jobs is full. Retry when some of them have started.
          param: null
          request_id: req_BTBBPZA0oirxnw23MU0t7E
    ErrDailyLimitReached:
      summary: daily_limit_reached
      description: "Today's job cap is reached. Retry after 00:00 UTC."
      value:
        error:
          code: daily_limit_reached
          message: Your daily job limit for this environment is reached. It resets at 00:00 UTC.
          param: null
          request_id: req_BTBBPZA0oirxnw23MU0t7E
    ErrBusy:
      summary: busy
      description: "We were briefly busy. Retry after Retry-After seconds."
      value:
        error:
          code: busy
          message: We are busy right now. Retry in a few seconds with the same Idempotency-Key; this is not your limit.
          param: null
          request_id: req_BTBBPZA0oirxnw23MU0t7E
    ErrInternal:
      summary: internal_error
      description: "Something went wrong on our side. Retry."
      value:
        error:
          code: internal_error
          message: Something went wrong on our side. Retry the request; for POST /v1/generations, use the same Idempotency-Key.
          param: null
          request_id: req_8d4DxP5n53EgewaJFOqGyS
    ErrServiceUnavailable:
      summary: service_unavailable
      description: "Part of the service is down. Retry after Retry-After seconds."
      value:
        error:
          code: service_unavailable
          message: The service is temporarily unavailable. Retry after the number of seconds in Retry-After.
          param: null
          request_id: req_8d4DxP5n53EgewaJFOqGyS
    ErrNotAcceptingJobs:
      summary: service_unavailable, not accepting new jobs
      description: "New jobs are paused; reading still works. Retry after Retry-After seconds."
      value:
        error:
          code: service_unavailable
          message: We are not accepting new jobs right now. Reading generations still works.
          param: null
          request_id: req_8d4DxP5n53EgewaJFOqGyS
    ErrOpensByInvitation:
      summary: service_unavailable, before access opens
      description: "Your access isn't open yet. It opens with your invitation."
      value:
        error:
          code: service_unavailable
          message: The FoodShot Partner API opens by invitation.
          param: null
          request_id: req_8d4DxP5n53EgewaJFOqGyS
    ErrQueryInvalid:
      summary: invalid_request, query parameter invalid
      description: "A query parameter has a wrong value."
      value:
        error:
          code: invalid_request
          message: The query parameter named in param is invalid.
          param: limit
          request_id: req_daJCWaeLcd6uXjfMaJeplG
    ErrCategoryInvalid:
      summary: invalid_request, unknown style category
      description: "The category isn't a style category."
      value:
        error:
          code: invalid_request
          message: The query parameter named in param is invalid.
          param: category
          request_id: req_daJCWaeLcd6uXjfMaJeplG
    ErrQueryUnknown:
      summary: invalid_request, unknown query parameter
      description: "The request has a query parameter the endpoint doesn't know."
      value:
        error:
          code: invalid_request
          message: The query parameter named in param is not part of the API.
          param: page
          request_id: req_daJCWaeLcd6uXjfMaJeplG
