openapi: 3.1.0
info:
  title: FaceNinja API
  version: "1.0"
  summary: Photos in, a MetaHuman-topology 3D head out.
  description: |
    FaceNinja turns one to fifteen ordinary photos of one person into a 3D head on MetaHuman topology, with neck and
    shoulders, 4K skin maps and all 52 ARKit blendshapes. The API is asynchronous: you upload photos, create a head, wait
    for it to finish, then ask for a file and follow a short-lived link.

    ```
    POST /v1/photos            upload each photo            -> ph_...
    POST /v1/heads             create the head              -> hd_...   status "generating"
    GET  /v1/heads/{id}?wait   wait (60 s at most, repeat)  -> status "completed"
    POST /v1/heads/{id}/files  {"format": "glb"}          -> a link that is valid for 60 seconds
    GET  <link>                download (no API key)
    ```

    The readable guide, with examples in curl, Node and Python, is at [/docs](/docs/).

    **Credits.** One US dollar buys 100 credits; a head costs 1,500 (`prices.head`). The credits are held when the head is
    created, kept when it completes and returned when it fails or is deleted before it finishes. Files, repeat downloads, dry runs and reads cost nothing. Prices are on the
    [pricing page](/pricing) and in `GET /v1/account`.

    **Errors** are always `{"error": {"code", "message", "field"?, "hint"?, "request_id"}}`. Branch on `code`; `message` is
    for people. Every response carries a `FaceNinja-Request-Id` header, the same value as `error.request_id`: quote it when
    you write to support.

    **Rate limits** are per account: 600 requests a minute across the API, 30 head creations a minute and 20 file links a
    minute per head. A limited request gets `429` with `Retry-After`.
  contact:
    name: FaceNinja
    url: https://faceninja.net/contact-sales
servers:
  - url: https://faceninja.net
    description: Production
security:
  - bearerAuth: []
tags:
  - name: Account
    description: Who you are and what is left on the balance.
  - name: Photos
    description: Upload photos before you create a head. An unused photo is deleted after 24 hours.
  - name: Heads
    description: Create a head, follow it to the end, download its files, delete it.
  - name: Webhooks
    description: Account-wide endpoints that receive head events. A single head can also carry its own `webhook_url`.

paths:
  /v1/account:
    get:
      tags: [Account]
      operationId: getAccount
      summary: Read the account
      description: Returns the credit balance (spendable and held) and the current prices. It is the cheapest authenticated call, so it doubles as a key check.
      responses:
        "200":
          description: The account.
          headers:
            FaceNinja-Request-Id: { $ref: "#/components/headers/RequestId" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Account" }
              example:
                object: account
                id: usr_8Qm2xF0cVwT1aKd3
                email: dev@example.com
                name: null
                credits: 6000
                held_credits: 1500
                prices:
                  currency: USD
                  head: 1500
                  credits_per_usd: 100
                  file: 0
                  price_per_head: 15
                  signup_credits: 1500
                  packs:
                    - { id: heads5, name: Batch, credits: 7500, heads: 5, price: 75, featured: true }
                  custom: { id: custom, min: 15, max: 5000, step: 5, credits_per_step: 500 }
                  referral_percent: 10
                  confirmed: true
                  note: Prices are in US dollars. Sales tax or VAT is added at checkout where it applies.
                created_at: "2026-10-23T14:22:17.007Z"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /v1/photos:
    post:
      tags: [Photos]
      operationId: uploadPhoto
      summary: Upload one photo
      description: |
        The body is the photo file itself; name its type in `Content-Type`
        (`image/jpeg`, `image/png`, `image/webp`, `image/heic`, `image/heif` or `image/avif`; HEIC/HEIF and AVIF are converted to JPEG
        on our side). The body is streamed into storage, so do not
        base64-encode it and do not use multipart here. `Content-Length` is required; chunked uploads are refused with `411`.

        The photo is yours for 24 hours. Pass its `id` in `photo_ids` when you create a head; once it belongs to a head it no
        longer expires. A photo can belong to one head only: upload it again for a second head.

        Limits: 25 MiB (26,214,400 bytes) per photo, at least 1,000 bytes. Large originals can be downscaled to about 4,000 px
        on the long side without costing quality here.
      parameters:
        - name: filename
          in: query
          required: false
          description: A label for your own bookkeeping, at most 120 characters. It is echoed back and used nowhere else.
          schema: { type: string, maxLength: 120 }
      requestBody:
        required: true
        content:
          image/jpeg: { schema: { type: string, format: binary } }
          image/png: { schema: { type: string, format: binary } }
          image/webp: { schema: { type: string, format: binary } }
          image/heic: { schema: { type: string, format: binary } }
          image/heif: { schema: { type: string, format: binary } }
          image/avif: { schema: { type: string, format: binary } }
      responses:
        "201":
          description: The photo is stored and `ready`.
          headers:
            FaceNinja-Request-Id: { $ref: "#/components/headers/RequestId" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Photo" }
              example:
                id: ph_BMrHdqYUvx7BlYb5TF0J
                object: photo
                status: ready
                content_type: image/jpeg
                bytes: 217214
                filename: front.jpg
                head_id: null
                created_at: "2026-10-23T14:22:22.790Z"
                expires_at: "2026-10-24T14:22:22.790Z"
        "400":
          description: The body is empty or smaller than 1,000 bytes (`invalid_request`).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "411":
          description: "`length_required`: the request has no `Content-Length`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "413":
          description: "`photo_too_large`: more than 25 MiB."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
              example:
                error:
                  code: photo_too_large
                  message: A photo can be at most 26214400 bytes.
                  hint: Downscale to about 4000 px on the long side; it does not lower quality for us.
                  request_id: req_Hq0yJm2kXb9TnWcD
        "415":
          description: "`unsupported_media_type`: `Content-Type` is not one of the five image types. `field` is `content_type`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /v1/photos/uploads:
    post:
      tags: [Photos]
      operationId: createUploadSlots
      summary: Get direct upload links
      description: |
        For clients that should not route photo bytes through the API (a browser, a phone, a server that already has the
        file in object storage). Say how many photos you will send and how big they are; you get one photo per slot, each
        with an `upload` object. `PUT` the file to `upload.url` with exactly the headers in `upload.headers`, within an hour.

        The photo stays `awaiting_upload` until the bytes arrive. It turns `ready` the next time you read it
        (`GET /v1/photos/{id}`) or use it in `POST /v1/heads`. A file larger than 25 MiB is rejected at that point with `413`.
        The upload link needs no API key: do not send one.

        A body that is a single file object instead of `{"files": [...]}` is read as one slot.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/UploadSlotsRequest" }
            example:
              files:
                - { content_type: image/jpeg, bytes: 1843201, filename: front.jpg }
                - { content_type: image/png, bytes: 902114 }
      responses:
        "201":
          description: One photo with an upload link per requested file, in the order you asked.
          headers:
            FaceNinja-Request-Id: { $ref: "#/components/headers/RequestId" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/UploadSlotList" }
              example:
                object: list
                data:
                  - id: ph_16aeWjSpD3YO3Bq0bn4T
                    object: photo
                    status: awaiting_upload
                    content_type: image/png
                    bytes: 123456
                    filename: side.png
                    head_id: null
                    created_at: "2026-10-23T14:22:34.006Z"
                    expires_at: "2026-10-24T14:22:34.006Z"
                    upload:
                      url: https://faceninja.net/files/photos/usr_.../ph_16aeWjSpD3YO3Bq0bn4T.png?e=1792768954&s=...
                      method: PUT
                      headers: { content-type: image/png }
                      expires_at: "2026-10-23T15:22:34.000Z"
        "400":
          description: "`invalid_request`: no files, more than 15, or a `bytes` value below 1,000 (`field` names the entry, for example `files[1].bytes`)."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "413":
          description: "`photo_too_large`: an announced size is above 25 MiB."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "415":
          description: "`unsupported_media_type`: an entry has a `content_type` that is not a supported image type."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /v1/photos/{id}:
    parameters:
      - $ref: "#/components/parameters/PhotoId"
    get:
      tags: [Photos]
      operationId: getPhoto
      summary: Read a photo
      description: Returns the photo. A photo that was set up with `/v1/photos/uploads` and has received its bytes turns `ready` here.
      responses:
        "200":
          description: The photo.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Photo" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "413":
          description: "`photo_too_large`: the uploaded object is larger than 25 MiB; it was discarded."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
    delete:
      tags: [Photos]
      operationId: deletePhoto
      summary: Delete an unused photo
      description: |
        Removes the stored bytes. Only a photo that is not part of a head can be deleted; a photo that belongs to a head
        goes away with the head (`DELETE /v1/heads/{id}`).
      responses:
        "200":
          description: "The photo with `status: deleted`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Photo" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: "`photo_in_use`: the photo belongs to a head."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /v1/heads:
    get:
      tags: [Heads]
      operationId: listHeads
      summary: List heads
      description: |
        Newest first. Deleted heads are left out unless you ask for `status=deleted`. In a list, `files` and `assets` are
        `null`; read a single head for them. Keep passing `next_cursor` back as `cursor`; the last page has none.
      parameters:
        - name: limit
          in: query
          description: Page size, 1 to 100.
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
        - name: status
          in: query
          description: Filter by status (for example `completed`).
          schema: { $ref: "#/components/schemas/HeadStatus" }
        - name: cursor
          in: query
          description: The `next_cursor` of the previous page. Treat it as an opaque string.
          schema: { type: string }
      responses:
        "200":
          description: A page of heads.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/HeadList" }
        "400":
          description: "`invalid_request`: unknown `status`, or a `cursor` that was not issued by this API."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
    post:
      tags: [Heads]
      operationId: createHead
      summary: Create a head
      description: |
        Starts a head from one to fifteen photos of one person. There is no separate "start" call: the head begins at once in
        status `generating`.

        **Two ways to send the photos.**
        `application/json` takes `photo_ids` of photos you uploaded earlier. `multipart/form-data` takes the photos
        themselves as repeated `photos` file parts (ids and files can be mixed), with the other fields as plain form fields
        and the options flat instead of nested in `options`. A multipart body is limited to 60 MiB; for more, upload photos
        one by one and send JSON.

        **Credits.** The price of one head (`prices.head`, 1,500 credits) is held when the head is accepted. `402 insufficient_credits` means nothing was created.

        **Waiting.** Without `wait` (or with `wait=0`) the answer is `202` and the head as it stands. `?wait` (no value) waits
        up to 60 seconds for a final state; `?wait=N` waits up to N seconds, never more than 60. With a positive `wait` the
        status is `200` even when the head is still running, so read `status`.

        **Retries.** Send an `Idempotency-Key` (any string up to 255 characters, kept for 24 hours). A repeat of the same
        request under that key answers `200` with the head it already made and reserves no credits a second time. The
        same key with a different body is `409 idempotency_conflict`.

        **Dry run.** `?dry_run=true` validates everything, including that the photos exist and are usable, and returns what
        it would cost and whether the balance is enough, without creating or holding anything. Files in a multipart dry run
        are validated but not stored.
      parameters:
        - $ref: "#/components/parameters/Wait"
        - name: dry_run
          in: query
          description: "`true` (or no value, or `1`) returns a `HeadQuote` and creates nothing."
          schema: { type: boolean, default: false }
          allowEmptyValue: true
        - name: Idempotency-Key
          in: header
          description: A unique string per intended head, at most 255 characters, for example a UUID. Keys are per account and expire after 24 hours.
          schema: { type: string, maxLength: 255 }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreateHeadJson" }
            examples:
              minimal:
                summary: Two photos, everything default
                value:
                  photo_ids: [ph_BMrHdqYUvx7BlYb5TF0J, ph_16aeWjSpD3YO3Bq0bn4T]
              withOptions:
                summary: GLB and FBX only, no groom recommendation, a per-head webhook
                value:
                  photo_ids: [ph_BMrHdqYUvx7BlYb5TF0J]
                  name: Intern portrait
                  options: { formats: [glb, fbx], arkit: true, groom: false }
                  webhook_url: https://api.example.org/faceninja-events
                  webhook_secret: a-long-random-string-you-keep
          multipart/form-data:
            schema: { $ref: "#/components/schemas/CreateHeadMultipart" }
            encoding:
              photos:
                contentType: image/jpeg, image/png, image/webp, image/heic, image/heif, image/avif, text/plain
      responses:
        "202":
          description: The head was created and is running. Poll, stream or wait for a webhook.
          headers:
            FaceNinja-Request-Id: { $ref: "#/components/headers/RequestId" }
            Retry-After: { $ref: "#/components/headers/RetryAfter" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Head" }
              example:
                id: hd_AzstNH0Hq8lfdiiHFmPP
                object: head
                name: Intern portrait
                status: generating
                stage: reference
                progress: 1
                message: Starting
                queue: { position: 1, agents_online: 2, builder: online, builder_note: null, start_s: 60, eta_s: 420 }
                photo_count: 2
                options:
                  formats: [glb, obj, stl, ply, fbx, metahuman_zip]
                  arkit: true
                  stubble: keep
                  groom: true
                  hair_preview: true
                credits: { cost: 1500, state: held }
                reference_ready: false
                files: []
                assets: []
                error: null
                source: api
                created_at: "2026-10-23T14:23:45.129Z"
                updated_at: "2026-10-23T14:23:45.129Z"
                completed_at: null
        "200":
          description: |
            One of three things. With `dry_run`, a `HeadQuote`. With a positive `wait`, the head as it stands when the wait
            ended (check `status`). When an idempotency key is replayed: the head that key already made.
          headers:
            FaceNinja-Request-Id: { $ref: "#/components/headers/RequestId" }
            Retry-After:
              description: Present while the head is not in a final state.
              schema: { type: integer }
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: "#/components/schemas/Head"
                  - $ref: "#/components/schemas/HeadQuote"
              examples:
                quote:
                  summary: Dry run
                  value:
                    object: head_quote
                    dry_run: true
                    photo_count: 1
                    options:
                      formats: [glb, obj, stl, ply, fbx, metahuman_zip]
                      arkit: true
                      stubble: keep
                      groom: true
                      hair_preview: true
                    credits: { cost: 1500, balance: 3000, sufficient: true }
        "400":
          description: |
            The request is wrong; a retry without changes will not help. Codes: `invalid_request` (unknown field or option,
            bad value, a photo id that is not yours, is deleted or already belongs to a head; `field` says which),
            `too_few_photos`, `too_many_photos`, `photo_expired`, `photo_not_uploaded`.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
              examples:
                unknownOption:
                  value:
                    error: { code: invalid_request, message: Unknown option "foo"., field: options.foo, request_id: req_4F0c1T3KkWxmcK5U }
                notUploaded:
                  value:
                    error:
                      code: photo_not_uploaded
                      message: Photo "ph_16aeWjSpD3YO3Bq0bn4T" has no uploaded bytes yet.
                      field: photo_ids[0]
                      hint: PUT the file to its upload URL first.
                      request_id: req_nDhBQHlTl9HiDoSc
        "401": { $ref: "#/components/responses/Unauthorized" }
        "402":
          description: "`insufficient_credits`: the balance is below the price of a head. Nothing was created or held."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
              example:
                error:
                  code: insufficient_credits
                  message: A head costs 1,500 credits; your balance is too low.
                  hint: Buy credits in Studio → Billing.
                  request_id: req_xe7FbiBZ76xdt1pr
        "409":
          description: |
            `idempotency_conflict`: the key was used with a different request, or a request with this key is still being
            accepted (then `Retry-After: 2` is set; retry with the same key).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "413":
          description: "`photo_too_large` (a photo above 25 MiB) or `request_too_large` (a multipart body above 60 MiB, or a JSON body above 64 KiB)."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "415":
          description: "`unsupported_media_type`: the body is neither JSON nor multipart, or an uploaded file is not a supported image type."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /v1/heads/{id}:
    parameters:
      - $ref: "#/components/parameters/HeadId"
    get:
      tags: [Heads]
      operationId: getHead
      summary: Read a head, optionally waiting
      description: |
        The answer is always `200` with the head, whatever its status. `?wait` (no value) holds the request open for up to
        60 seconds and returns as soon as the head is `completed`, `failed` or `deleted`; `?wait=N` caps the hold at N seconds.
        A wait that ends early because the time ran out returns the head as it is, so loop until `status` is final.

        While the head is not final the response carries `Retry-After` (10 seconds during `generating`, 30 afterwards), a hint
        for clients that poll without `wait`. A deleted head can still be read; it has no files.
      parameters:
        - $ref: "#/components/parameters/Wait"
      responses:
        "200":
          description: The head.
          headers:
            FaceNinja-Request-Id: { $ref: "#/components/headers/RequestId" }
            Retry-After:
              description: Present while the head is not in a final state.
              schema: { type: integer }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Head" }
              examples:
                completed:
                  summary: A finished head
                  value:
                    id: hd_AzstNH0Hq8lfdiiHFmPP
                    object: head
                    name: Intern portrait
                    status: completed
                    stage: null
                    progress: 100
                    message: null
                    queue: null
                    photo_count: 2
                    options:
                      formats: [glb, obj, stl, ply, fbx, metahuman_zip]
                      arkit: true
                      stubble: keep
                      groom: true
                      hair_preview: true
                    credits: { cost: 1500, state: captured }
                    reference_ready: true
                    files:
                      - { format: glb, arkit: true, bytes: 18431022, sha256: 26e96b8b118821d2dd9e938662e27834f71a9d440d1da4520ab71c49d559fba1, label: GLB }
                      - { format: glb, arkit: false, bytes: 17911844, sha256: d13c24a1b2661a5474a8aa6a0fb87a171d7e982bc61e7ed18a6e6123204372e6, label: GLB }
                      - { format: metahuman_zip, arkit: false, bytes: 52480331, sha256: 7eefcf442638f8b3481ba92c834c8cde2e3079454c5ee78cb666bace3e54e9c4, label: MetaHuman ZIP }
                    assets:
                      - { kind: compare, bytes: 412093 }
                      - { kind: groom_json, bytes: 3011 }
                      - { kind: preview_glb, bytes: 11304522 }
                      - { kind: reference, bytes: 2210493 }
                    error: null
                    source: api
                    created_at: "2026-10-23T14:23:45.129Z"
                    updated_at: "2026-10-23T14:34:02.658Z"
                    completed_at: "2026-10-23T14:34:02.658Z"
                failed:
                  summary: A failed head (the credits were released)
                  value:
                    id: hd_Ck2lXe4FyS8xHl3xcmZn
                    object: head
                    name: null
                    status: failed
                    stage: null
                    progress: null
                    message: null
                    queue: null
                    photo_count: 1
                    options:
                      formats: [glb, obj, stl, ply, fbx, metahuman_zip]
                      arkit: true
                      stubble: keep
                      groom: true
                      hair_preview: true
                    credits: { cost: 1500, state: released }
                    reference_ready: true
                    files: []
                    assets:
                      - { kind: reference, bytes: 2198311 }
                    error: { code: no_face, message: We could not find a face in the photos. Your credits were returned. }
                    source: api
                    created_at: "2026-10-23T14:25:03.464Z"
                    updated_at: "2026-10-23T14:25:48.131Z"
                    completed_at: "2026-10-23T14:25:48.131Z"
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
    delete:
      tags: [Heads]
      operationId: deleteHead
      summary: Delete a head
      description: |
        Deletes the head, its photos and every file, whatever its status. A head that is deleted **before it finished** gets
        its credits back (`credits.state` becomes `released`) and any work in flight is cancelled. A head that had already
        completed keeps its credits captured. Deleting cannot be undone; the head stays readable with `status: deleted` and no
        files. A second delete is `404`.
      responses:
        "200":
          description: "The head with `status: deleted`."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/Head" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /v1/heads/{id}/events:
    parameters:
      - $ref: "#/components/parameters/HeadId"
    get:
      tags: [Heads]
      operationId: streamHeadEvents
      summary: Follow a head (Server-Sent Events)
      description: |
        A stream of `text/event-stream`. Every connection starts with `retry: 3000` and one `head` event that carries the
        complete head (`data` is a `Head`), so a client that connects late still knows where things stand. After that, each
        change arrives as it happens:

        | `event` | `data` |
        | --- | --- |
        | `head` | the full `Head`. Sent first, and again when the head reaches a final state |
        | `status` | `{"status", "stage"?, "reference_ready"?, "error"?, "retry"?, "attempt"?}` on each status change |
        | `progress` | `{"stage", "progress", "message"}` while the head is processing |

        Each event has an `id`. A `: ping` comment line arrives about every 15 seconds on a quiet stream.

        The stream closes by itself when the head reaches a final state, and after about 55 seconds otherwise. A browser
        `EventSource` reconnects on its own and sends `Last-Event-ID`; any other client should do the same. Events that
        happened in between are replayed, which can repeat information the opening `head` event already carried, so apply
        events idempotently. `EventSource` cannot set an `Authorization` header: call this from a server, or use `fetch`
        with a stream reader.
      parameters:
        - name: Last-Event-ID
          in: header
          description: The `id` of the last event you processed. The query parameter `last_event_id` does the same for clients that cannot set headers.
          schema: { type: integer }
      responses:
        "200":
          description: An event stream.
          headers:
            FaceNinja-Request-Id: { $ref: "#/components/headers/RequestId" }
          content:
            text/event-stream:
              schema: { type: string }
              example: |
                retry: 3000

                id: 2
                event: head
                data: {"id":"hd_AzstNH0Hq8lfdiiHFmPP","object":"head","status":"generating", ...}

                id: 3
                event: status
                data: {"status":"queued","stage":"waiting","reference_ready":true}

                : ping

                id: 5
                event: progress
                data: {"stage":"fit","progress":46,"message":"Fitting the face"}

                id: 8
                event: status
                data: {"status":"completed"}

                id: 8
                event: head
                data: {"id":"hd_AzstNH0Hq8lfdiiHFmPP","object":"head","status":"completed", ...}
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /v1/heads/{id}/files/{kind}:
    parameters:
      - $ref: "#/components/parameters/HeadId"
      - name: kind
        in: path
        required: true
        description: A download format (`glb`, `obj`, `stl`, `ply`, `fbx`, `metahuman_zip`) or a preview asset. See the file table in the guide for what each contains.
        schema: { $ref: "#/components/schemas/FileKind" }
    get:
      tags: [Heads]
      operationId: getFileLink
      summary: Get a download link
      description: |
        Returns a link for one file of a **completed** head. Everything the head built is available and every download is
        included in the head's price. The link is ready at once (there is nothing to prepare) and is valid for 60 seconds to
        start the download; a transfer that has begun finishes. It needs no API key, so do not send one to it, and do not
        store it: store the head id and ask again. Resumed or repeated downloads need a fresh link. The link supports
        `Range` requests.

        `?redirect=true` answers with `302` and the link in `Location` instead of JSON, so `curl -L` can download in one call.

        **ARKit.** `arkit` applies to `glb` and `fbx`, the formats that can carry the 52 ARKit blendshapes. Leave it out
        to get the variant the head was created for (`options.arkit`); `arkit=true` or `arkit=false` asks for one explicitly.
        The head's `files` array lists the variants that exist. Any other format with `arkit=true` is `400`.

        **Early reference.** The neutral reference portrait (`kind=reference`) can be fetched as soon as the head's
        `reference_ready` is `true`, long before the head completes. Every other file needs `status: completed`.

        **Download options.** `texture`, `normal_map`, `texture_size`, `cornea` and `scale` ask for the file in another
        form (see `FileOptions`). Defaults are the file the build made, ready at once. Any other set is prepared once on
        its first request and kept: until then the answer is `202` with a `FileVariant` and `Retry-After`; `wait` (≤ 25 s)
        holds the request while it is prepared. The MetaHuman ZIP takes no options.
      parameters:
        - name: arkit
          in: query
          description: Pick the ARKit (`true`) or the static (`false`) variant of a `glb` or `fbx`.
          schema: { type: boolean }
        - { name: texture, in: query, schema: { $ref: "#/components/schemas/FileOptions/properties/texture" } }
        - { name: normal_map, in: query, schema: { $ref: "#/components/schemas/FileOptions/properties/normal_map" } }
        - { name: texture_size, in: query, schema: { $ref: "#/components/schemas/FileOptions/properties/texture_size" } }
        - { name: cornea, in: query, schema: { $ref: "#/components/schemas/FileOptions/properties/cornea" } }
        - { name: scale, in: query, schema: { $ref: "#/components/schemas/FileOptions/properties/scale" } }
        - name: wait
          in: query
          description: Seconds (0–25) to hold the request while a variant with download options is prepared.
          schema: { type: integer, minimum: 0, maximum: 25, default: 0 }
        - name: redirect
          in: query
          description: "`true` or `1` returns `302` with the link in `Location`."
          schema: { type: boolean, default: false }
      responses:
        "200":
          description: The link.
          headers:
            FaceNinja-Request-Id: { $ref: "#/components/headers/RequestId" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/FileLink" }
              example:
                object: file_link
                format: glb
                arkit: true
                url: https://faceninja.net/files/heads/hd_AzstNH0Hq8lfdiiHFmPP/out/glb_arkit.glb?e=1792765543&s=2ea134...&n=Intern_portrait-glb-arkit.glb
                expires_at: "2026-10-23T14:25:43.000Z"
                bytes: 18431022
                sha256: 26e96b8b118821d2dd9e938662e27834f71a9d440d1da4520ab71c49d559fba1
                filename: Intern_portrait-glb-arkit.glb
        "202": { $ref: "#/components/responses/VariantPreparing" }
        "302":
          description: With `redirect=true`.
          headers:
            Location:
              description: The download link.
              schema: { type: string, format: uri }
        "400":
          description: "`invalid_request`: `kind` is not a format or asset (`field: format`), `arkit` is not a boolean, `arkit=true` was asked for a format without ARKit (`field: arkit`), or a download option has an unknown value. `field_not_applicable`: a non-default download option for a format that does not take it."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404":
          description: "`not_found` (no such head, or it was deleted) or `variant_not_available` (the head has no file of that kind and ARKit variant; `hint` lists what exists)."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
              examples:
                variant:
                  value:
                    error:
                      code: variant_not_available
                      message: This head has no glb without ARKit file.
                      hint: "Available: glb+arkit, fbx+arkit, obj."
                      request_id: req_8dVn0wQeLk2uPzYa
        "409":
          description: "`head_failed`: the head failed and has no files. `hint` repeats the reason."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "410":
          description: "`files_expired`: the retention period ended and the head's photos and files were deleted (`files_expire_at`). The head itself stays readable."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "425":
          description: "`head_not_ready`: the head is not `completed` yet. `Retry-After: 30` is set. Wait with `?wait`, the event stream or a webhook rather than polling this call."
          headers:
            Retry-After: { $ref: "#/components/headers/RetryAfter" }
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /v1/heads/{id}/files:
    parameters:
      - $ref: "#/components/parameters/HeadId"
    post:
      tags: [Heads]
      operationId: createFileLink
      summary: Get a download link (body form)
      description: |
        The same as `GET /v1/heads/{id}/files/{kind}`, with the choice in the body: JSON `{"format", "arkit"?, "redirect"?}`
        plus any download options (top level or in `options`), or form fields, so `curl … -d format=glb` works. Nothing is
        charged; the call can be repeated freely (a new option set is prepared once, then served from storage).
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/FileLinkRequest" }
            examples:
              plain: { value: { format: glb, arkit: true } }
              options: { value: { format: glb, texture: png, normal_map: directx, texture_size: 2k, wait: 25 } }
          application/x-www-form-urlencoded:
            schema: { $ref: "#/components/schemas/FileLinkRequest" }
      responses:
        "200":
          description: The link (as for the GET form).
          content:
            application/json:
              schema: { $ref: "#/components/schemas/FileLink" }
        "202": { $ref: "#/components/responses/VariantPreparing" }
        "302":
          description: With `redirect=true`.
          headers:
            Location:
              description: The download link.
              schema: { type: string, format: uri }
        "400":
          description: "`invalid_request`: no `format`, an unknown format or field, `arkit=true` for a format without ARKit, or a download option with an unknown value. `field_not_applicable`: a non-default download option for a format that does not take it."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "409":
          description: "`head_failed`: the head failed and has no files."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "410":
          description: "`files_expired`: the retention period ended and the files were deleted (see `files_expire_at`)."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "425":
          description: "`head_not_ready`: the head is not `completed` yet (`Retry-After: 30`)."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "429": { $ref: "#/components/responses/RateLimited" }

  /v1/webhooks:
    get:
      tags: [Webhooks]
      operationId: listWebhooks
      summary: List webhook endpoints and recent deliveries
      description: Your endpoints, plus the last 20 delivery attempts across them, which is the first place to look when events seem to be missing.
      responses:
        "200":
          description: The endpoints and recent deliveries.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WebhookEndpointList" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }
    post:
      tags: [Webhooks]
      operationId: createWebhook
      summary: Add a webhook endpoint
      description: |
        Registers an HTTPS URL that receives events for every head on the account. The response contains the signing
        `secret` (`whsec_…`) **once**; store it. At most 5 endpoints per account. See the guide for the payloads, the
        signature and the retry schedule, and the `webhooks` section of this document for the payload schemas.
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/CreateWebhookRequest" }
            example:
              url: https://api.example.org/faceninja-events
              events: [head.completed, head.failed]
      responses:
        "201":
          description: The endpoint, with its secret.
          content:
            application/json:
              schema: { $ref: "#/components/schemas/WebhookEndpointCreated" }
              example:
                id: whe_fpE30bX6l7OxMGLg
                object: webhook_endpoint
                url: https://api.example.org/faceninja-events
                events: [head.completed, head.failed]
                secret: whsec_dGcGE9KNM5rZE7udi6Ua6tjCzTLevgeWYOYA
                created_at: "2026-10-23T14:23:37.383Z"
                note: The secret is shown once.
        "400":
          description: "`invalid_request` (the `url` is not a public https URL, or an `events` entry is unknown; `field` says which) or `limit_reached` (5 endpoints already)."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "413":
          description: "`request_too_large`: the JSON body is above 64 KiB."
          content:
            application/json:
              schema: { $ref: "#/components/schemas/ErrorResponse" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

  /v1/webhooks/{id}:
    delete:
      tags: [Webhooks]
      operationId: deleteWebhook
      summary: Remove a webhook endpoint
      description: Stops new deliveries to the endpoint. Deliveries that are already scheduled for retry are not sent.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string, pattern: "^whe_" }
      responses:
        "200":
          description: Removed.
          content:
            application/json:
              schema:
                type: object
                required: [ok]
                properties:
                  ok: { type: boolean, const: true }
        "401": { $ref: "#/components/responses/Unauthorized" }
        "404": { $ref: "#/components/responses/NotFound" }
        "429": { $ref: "#/components/responses/RateLimited" }
        "500": { $ref: "#/components/responses/InternalError" }

webhooks:
  head.completed:
    post:
      tags: [Webhooks]
      operationId: onHeadCompleted
      summary: A head completed
      description: |
        Sent once when a head reaches `completed`; `data` is the complete head with its `files` and `assets`. Delivered to
        your account endpoints that subscribe to `head.completed` and to the head's own `webhook_url`.
        Verify `FaceNinja-Signature` before you trust it (see the guide).
      parameters:
        - $ref: "#/components/parameters/WebhookSignature"
        - $ref: "#/components/parameters/WebhookEvent"
        - $ref: "#/components/parameters/WebhookDelivery"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/HeadCompletedEvent" }
      responses:
        "2XX":
          description: Acknowledge with any 2xx within 10 seconds. Anything else, including a redirect, is retried.
  head.failed:
    post:
      tags: [Webhooks]
      operationId: onHeadFailed
      summary: A head failed
      description: |
        Sent once when a head reaches `failed`. The credits have already been released. `data.error` holds the code and a plain
        sentence; `data.files` is empty.
      parameters:
        - $ref: "#/components/parameters/WebhookSignature"
        - $ref: "#/components/parameters/WebhookEvent"
        - $ref: "#/components/parameters/WebhookDelivery"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/HeadFailedEvent" }
      responses:
        "2XX":
          description: Acknowledge with any 2xx within 10 seconds.
  head.reference_ready:
    post:
      tags: [Webhooks]
      operationId: onHeadReferenceReady
      summary: The neutral reference is ready
      description: |
        Sent when the cloud step has drawn the neutral reference portrait and the head moves from `generating` to `queued`.
        The reference can be downloaded from then on (`kind=reference`). Only account endpoints that subscribe to this event
        receive it; a per-head `webhook_url` does not.
      parameters:
        - $ref: "#/components/parameters/WebhookSignature"
        - $ref: "#/components/parameters/WebhookEvent"
        - $ref: "#/components/parameters/WebhookDelivery"
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: "#/components/schemas/HeadReferenceReadyEvent" }
      responses:
        "2XX":
          description: Acknowledge with any 2xx within 10 seconds.

components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: "fn_live_..."
      description: |
        An API key from Studio (Studio, API keys), sent as `Authorization: Bearer fn_live_...`. A key can do everything the
        account can except create or revoke keys, which is a Studio action. Keep keys on a server.

  headers:
    RequestId:
      description: Identifies this request in our logs. Quote it when you contact support.
      schema: { type: string, examples: [req_qqSjvzPlHra60k9I] }
    RetryAfter:
      description: Seconds to wait before asking again.
      schema: { type: integer }

  parameters:
    HeadId:
      name: id
      in: path
      required: true
      description: A head id, `hd_...`.
      schema: { type: string, pattern: "^hd_" }
    PhotoId:
      name: id
      in: path
      required: true
      description: A photo id, `ph_...`.
      schema: { type: string, pattern: "^ph_" }
    Wait:
      name: wait
      in: query
      required: false
      description: |
        Seconds to hold the request open for a final state. No value means 60; larger values are treated as 60, and 0 (the
        default) returns at once. Loop until `status` is `completed`, `failed` or `deleted`.
      schema: { type: integer, minimum: 0 }
      allowEmptyValue: true
    WebhookSignature:
      name: FaceNinja-Signature
      in: header
      required: false
      description: |
        `t=<unix seconds>,v1=<hex>`, where `v1` is the hex HMAC-SHA256 of `"<t>.<raw request body>"` with your signing secret as
        the key. Present whenever the delivery has a secret: always for account endpoints, and for a per-head `webhook_url`
        only when the head was created with `webhook_secret`. The timestamp is renewed on each retry.
      schema: { type: string, examples: ["t=1792765472,v1=5c34efeea61497..."] }
    WebhookEvent:
      name: FaceNinja-Event
      in: header
      required: true
      description: The event type, same as `type` in the body.
      schema: { $ref: "#/components/schemas/WebhookEventType" }
    WebhookDelivery:
      name: FaceNinja-Delivery
      in: header
      required: true
      description: Identifies the delivery (one event to one URL). It is the same on every retry of that delivery.
      schema: { type: string, examples: [whd_LK1bhfjGy47V0er1] }

  responses:
    Unauthorized:
      description: "`unauthorized`: the key is missing, malformed, revoked or unknown."
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
          example:
            error:
              code: unauthorized
              message: 'Send a valid API key as "Authorization: Bearer fn_live_…", or sign in.'
              request_id: req_tVZDXfgur07aIAYW
    VariantPreparing:
      description: |
        The file with these download options is being prepared (first request of this option set). Ask again after
        `Retry-After` seconds, or pass `wait`. `422 variant_failed`: it could not be made (tried again when asked after
        10 minutes). `429 too_many_variants`: the head already keeps 24 prepared variants.
      headers:
        Retry-After: { $ref: "#/components/headers/RetryAfter" }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/FileVariant" }
          example:
            object: file_variant
            id: fv_Yw8u2nQd0e1KxR5tL7aB
            format: glb
            arkit: true
            variant: png-directx-2k-blend-template
            options: { texture: png, normal_map: directx, texture_size: 2k, cornea: blend, scale: template }
            status: preparing
            retry_after: 15
    NotFound:
      description: "`not_found`: nothing with this id on your account (ids of other accounts look the same)."
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
          example:
            error: { code: not_found, message: No head with this id., request_id: req_EnGELZWEOryM7v7k }
    RateLimited:
      description: "`rate_limited`: too many requests. Wait `Retry-After` seconds."
      headers:
        Retry-After: { $ref: "#/components/headers/RetryAfter" }
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }
          example:
            error: { code: rate_limited, message: "Too many requests; slow down and retry after a minute.", request_id: req_WmHKFv6LUbLYd3jP }
    InternalError:
      description: "`internal_error`: our side failed. Retrying is safe; quote `request_id` if it keeps happening."
      content:
        application/json:
          schema: { $ref: "#/components/schemas/ErrorResponse" }

  schemas:
    ErrorResponse:
      type: object
      required: [error]
      properties:
        error: { $ref: "#/components/schemas/Error" }

    Error:
      type: object
      required: [code, message, request_id]
      properties:
        code:
          type: string
          description: |
            Stable and machine-readable. Codes this API returns: `invalid_request`, `too_few_photos`, `too_many_photos`,
            `photo_expired`, `photo_not_uploaded`, `photo_too_large`, `photo_in_use`, `request_too_large`,
            `unsupported_media_type`, `length_required`, `unauthorized`, `insufficient_credits`, `not_found`,
            `variant_not_available`, `method_not_allowed`, `idempotency_conflict`, `head_failed`, `head_not_ready`,
            `limit_reached`, `rate_limited`, `internal_error`, `field_not_applicable`, `variant_failed`,
            `too_many_variants`. New codes can appear; treat an unknown code by its HTTP status.
          examples: [invalid_request]
        message: { type: string, description: "A sentence for a person. Do not match on it." }
        field:
          type: string
          description: The request field at fault, for example `photo_ids[1]`, `options.formats` or `arkit`.
        hint: { type: string, description: "What to do about it, when we know." }
        request_id: { type: string, examples: [req_qqSjvzPlHra60k9I] }

    Account:
      type: object
      required: [object, id, email, credits, held_credits, prices, created_at]
      properties:
        object: { type: string, const: account }
        id: { type: string, examples: [usr_8Qm2xF0cVwT1aKd3] }
        email: { type: string, format: email }
        name: { type: [string, "null"] }
        credits:
          type: integer
          description: Credits you can spend now. Credits held by running heads are not included.
        held_credits:
          type: integer
          description: Credits held by heads that have not finished. They are kept when the head completes and returned if it fails.
        prices: { $ref: "#/components/schemas/Prices" }
        created_at: { type: string, format: date-time }

    Prices:
      type: object
      properties:
        currency: { type: string, examples: [USD] }
        head: { type: integer, description: "Credits one head costs (1,500)." }
        credits_per_usd: { type: integer, description: "Credits one US dollar buys (100)." }
        file: { type: integer, description: "Credits a file costs. Always 0; every format and every repeat download is included." }
        price_per_head: { type: number, description: "Price of one head bought on its own, in `currency`." }
        signup_credits: { type: integer, description: "Credits a new account starts with." }
        packs:
          type: array
          items:
            type: object
            properties:
              id: { type: string }
              name: { type: string }
              credits: { type: integer }
              heads: { type: number }
              price: { type: number, description: "In `currency`, before tax." }
              featured: { type: boolean }
        custom:
          type: [object, "null"]
          description: "Custom amounts: `min` to `max` dollars in steps of `step`, `credits_per_step` credits per step."
          properties:
            id: { type: string }
            min: { type: number }
            max: { type: number }
            step: { type: number }
            credits_per_step: { type: integer }
        referral_percent: { type: number, description: "Share of a referred account's purchased credits its referrer receives." }
        confirmed: { type: boolean, description: "`false` while the published prices are still provisional." }
        note: { type: [string, "null"], description: "For example whether tax is included." }

    PhotoStatus:
      type: string
      enum: [awaiting_upload, ready, attached, deleted]
      description: |
        `awaiting_upload`: a slot from `/v1/photos/uploads` whose bytes have not arrived. `ready`: stored and unused.
        `attached`: part of a head. `deleted`.

    Photo:
      type: object
      required: [id, object, status, content_type, bytes, head_id, created_at, expires_at]
      properties:
        id: { type: string, examples: [ph_BMrHdqYUvx7BlYb5TF0J] }
        object: { type: string, const: photo }
        status: { $ref: "#/components/schemas/PhotoStatus" }
        content_type: { type: string, examples: [image/jpeg] }
        bytes: { type: [integer, "null"], description: "For an upload slot, the size you announced until the bytes arrive." }
        filename: { type: [string, "null"] }
        head_id: { type: [string, "null"], description: "The head the photo belongs to, once it is attached." }
        created_at: { type: string, format: date-time }
        expires_at:
          type: [string, "null"]
          format: date-time
          description: When an unused photo is deleted (24 hours after upload). `null` once the photo belongs to a head.

    UploadSlotsRequest:
      type: object
      required: [files]
      properties:
        files:
          type: array
          minItems: 1
          maxItems: 15
          items:
            type: object
            required: [content_type, bytes]
            properties:
              content_type:
                type: string
                enum: [image/jpeg, image/png, image/webp, image/heic, image/heif, image/avif]
              bytes: { type: integer, minimum: 1000, maximum: 26214400, description: "The size of the file you will upload." }
              filename: { type: string, maxLength: 120 }

    UploadSlot:
      allOf:
        - $ref: "#/components/schemas/Photo"
        - type: object
          required: [upload]
          properties:
            upload:
              type: object
              required: [url, method, headers, expires_at]
              properties:
                url: { type: string, format: uri }
                method: { type: string, const: PUT }
                headers:
                  type: object
                  description: Send exactly these headers with the PUT. Today that is `content-type`.
                  additionalProperties: { type: string }
                expires_at: { type: string, format: date-time, description: "The link works for one hour." }

    UploadSlotList:
      type: object
      required: [object, data]
      properties:
        object: { type: string, const: list }
        data:
          type: array
          items: { $ref: "#/components/schemas/UploadSlot" }

    Format:
      type: string
      enum: [glb, obj, stl, ply, fbx, metahuman_zip]
      description: A download format. All of them are built from the same run.

    AssetKind:
      type: string
      enum: [preview_glb, hair_preview_glb, groom_json, groom_md, groom_sheet, unreal_sheet, compare, render_front, reference]
      description: |
        Previews and by-products. `preview_glb`: a light web-viewer model. `hair_preview_glb`: hair for the viewer, to judge the
        likeness (not a MetaHuman asset). `groom_json`, `groom_md`, `groom_sheet`: the recommended MetaHuman hair, brow and
        beard grooms with colours, as data, as text and as an image sheet. `unreal_sheet`: the Unreal result, present only when
        that step ran. `compare`: photo, reference and renders side by side. `render_front`: a front render.
        `reference`: the neutral reference portrait.

    FileKind:
      description: Anything you can ask a link for.
      oneOf:
        - $ref: "#/components/schemas/Format"
        - $ref: "#/components/schemas/AssetKind"

    HeadStatus:
      type: string
      enum: [generating, queued, processing, completed, failed, deleted]
      description: |
        `generating`: the neutral reference portrait is being drawn in the cloud. `queued`: the reference is done and the
        head waits for a free worker. `processing`: a worker is building the head. `completed`: files are ready.
        `failed`: final, the credits were released, see `error`. `deleted`: final. `completed`, `failed` and `deleted` never change again.

    HeadOptions:
      type: object
      description: What was asked for; the defaults fill anything you left out.
      properties:
        formats:
          type: array
          items: { $ref: "#/components/schemas/Format" }
          description: Formats to build. Default all six.
        arkit:
          type: boolean
          default: true
          description: Build the 52 ARKit blendshapes into `glb` and `fbx`. It also decides which variant a file request returns when you do not pass `arkit`.
        stubble:
          type: string
          enum: [keep, none]
          default: keep
          description: "`keep` leaves beard shadow in the skin map. `none` removes it, which suits a MetaHuman beard groom placed on top."
        groom:
          type: boolean
          default: true
          description: Produce the groom recommendation (`groom_json`, `groom_md`, `groom_sheet`).
        hair_preview:
          type: boolean
          default: true
          description: Produce `hair_preview_glb`.

    HeadFile:
      type: object
      required: [format, arkit, bytes, sha256, label]
      properties:
        format: { $ref: "#/components/schemas/Format" }
        arkit: { type: boolean, description: "Whether this variant carries the ARKit blendshapes. Only `glb` and `fbx` can." }
        bytes: { type: [integer, "null"] }
        sha256: { type: [string, "null"], description: "Hex SHA-256 of the file, for verifying a download." }
        label: { type: string }

    HeadAsset:
      type: object
      required: [kind, bytes]
      properties:
        kind: { $ref: "#/components/schemas/AssetKind" }
        bytes: { type: [integer, "null"] }

    Head:
      type: object
      required: [id, object, status, credits, reference_ready, created_at, updated_at]
      properties:
        id: { type: string, examples: [hd_AzstNH0Hq8lfdiiHFmPP] }
        object: { type: string, const: head }
        name: { type: [string, "null"], description: "Your label from the create call." }
        status: { $ref: "#/components/schemas/HeadStatus" }
        stage:
          type: [string, "null"]
          description: A short label for the step in progress (for example `reference`, `waiting`, `fit`, `texture`, `export`). For display only; new values can appear. `null` once the head is final.
        progress:
          type: [integer, "null"]
          minimum: 0
          maximum: 100
          description: Rough overall progress. It does not run at a steady rate. `null` for `failed` and `deleted` heads.
        message: { type: [string, "null"], description: "A line for display. On a failed head read `error`, not this." }
        queue:
          type: [object, "null"]
          description: Present while the head is `generating` or `queued`.
          properties:
            position: { type: integer, description: "Place in the line for a builder (1 is next)." }
            agents_online: { type: integer, description: "Builders that are online (taking work or busy)." }
            builder: { type: string, enum: [online, busy, offline], description: "online: a builder takes work now; busy: builders are online but occupied; offline: none answers." }
            builder_note: { type: [string, "null"], enum: [building, other_work, paused, updating, null], description: "Why the builders are busy." }
            start_s: { type: [integer, "null"], description: "Estimated seconds until the build starts; null while no builder takes work." }
            eta_s: { type: [integer, "null"], description: "Estimated seconds until the head is ready; null while no builder takes work." }
        photo_count: { type: integer }
        options: { $ref: "#/components/schemas/HeadOptions" }
        credits:
          type: object
          properties:
            cost: { type: integer }
            state:
              type: string
              enum: [held, captured, released]
              description: "`held` while the head runs, `captured` after it completed, `released` when it failed or was deleted before completion (the credits are back on the balance)."
        reference_ready: { type: boolean, description: "`true` once the neutral reference can be downloaded (`kind=reference`)." }
        files:
          type:
            - array
            - "null"
          items: { $ref: "#/components/schemas/HeadFile" }
          description: The downloadable formats built. `null` in list responses.
        assets:
          type:
            - array
            - "null"
          items: { $ref: "#/components/schemas/HeadAsset" }
          description: Previews and by-products available. `null` in list responses.
        error:
          type: [object, "null"]
          description: Set when `status` is `failed`.
          required: [code, message]
          properties:
            code:
              type: string
              description: |
                `no_face`, `multiple_people`, `not_same_person`, `unreadable_photo`, `content_rejected`, `generation_failed`, `timeout` or
                `internal`. All of them released the credits.
            message: { type: string }
        source: { type: string, enum: [api, studio], description: "Where the head was created." }
        created_at: { type: string, format: date-time }
        updated_at: { type: string, format: date-time }
        completed_at: { type: [string, "null"], format: date-time, description: "When the head reached `completed` or `failed`." }
        files_expire_at: { type: [string, "null"], format: date-time, description: "When the photos and files of a finished head are deleted (30 days after it finished). Download what you need before then." }
        files_purged: { type: boolean, description: "`true` once they were deleted; the head's status and details remain." }

    HeadList:
      type: object
      required: [object, data, next_cursor]
      properties:
        object: { type: string, const: list }
        data:
          type: array
          items: { $ref: "#/components/schemas/Head" }
        next_cursor: { type: [string, "null"], description: "Pass as `cursor` for the next page; `null` on the last page." }

    HeadQuote:
      type: object
      description: What a dry run returns.
      required: [object, dry_run, photo_count, options, credits]
      properties:
        object: { type: string, const: head_quote }
        dry_run: { type: boolean, const: true }
        photo_count: { type: integer }
        options: { $ref: "#/components/schemas/HeadOptions" }
        credits:
          type: object
          properties:
            cost: { type: integer }
            balance: { type: integer, description: "Spendable credits right now." }
            sufficient: { type: boolean, description: "Whether `balance` covers `cost`." }

    CreateHeadJson:
      type: object
      required: [photo_ids]
      additionalProperties: false
      properties:
        photo_ids:
          type: array
          minItems: 1
          maxItems: 15
          uniqueItems: true
          items: { type: string, pattern: "^ph_" }
          description: Photos of one person, in the order you want them used (the first is the main one).
        photos:
          type: array
          deprecated: true
          items: { type: string, pattern: "^ph_" }
          description: An alias of `photo_ids`, accepted so that code written for other services keeps working. Use `photo_ids`.
        name: { type: string, maxLength: 120, description: "Your label for the head." }
        options: { $ref: "#/components/schemas/HeadOptions" }
        webhook_url:
          type: string
          format: uri
          maxLength: 2000
          description: A public https URL that receives `head.completed` and `head.failed` for this head, on top of your account endpoints.
        webhook_secret:
          type: string
          minLength: 16
          maxLength: 200
          description: Signs the deliveries to `webhook_url`. Needs `webhook_url`; never returned. Without it those deliveries are sent unsigned.

    CreateHeadMultipart:
      type: object
      description: |
        The same fields as the JSON body, with the options flat. Booleans are the strings `true` or `false`. `formats` is a
        comma-separated list (`glb,fbx`).
      properties:
        photos:
          type: array
          minItems: 1
          maxItems: 15
          items:
            type: string
            format: binary
          description: Repeat the field once per photo. Each part is an image file (JPEG, PNG or WebP; at most 25 MiB) or the text of a photo id.
        name: { type: string, maxLength: 120 }
        arkit: { type: boolean }
        formats: { type: string, examples: ["glb,fbx"] }
        stubble: { type: string, enum: [keep, none] }
        groom: { type: boolean }
        hair_preview: { type: boolean }
        webhook_url: { type: string, format: uri }
        webhook_secret: { type: string, minLength: 16, maxLength: 200 }

    FileLinkRequest:
      type: object
      additionalProperties: false
      required: [format]
      properties:
        format: { $ref: "#/components/schemas/FileKind" }
        arkit: { type: boolean, description: "Pick the ARKit or the static variant of a glb or fbx." }
        redirect: { type: boolean, default: false, description: "Answer 302 with the link in Location." }
        wait: { type: integer, minimum: 0, maximum: 25, default: 0, description: "Seconds to hold the request while a variant with download options is prepared." }
        options: { $ref: "#/components/schemas/FileOptions" }
        texture: { $ref: "#/components/schemas/FileOptions/properties/texture" }
        normal_map: { $ref: "#/components/schemas/FileOptions/properties/normal_map" }
        texture_size: { $ref: "#/components/schemas/FileOptions/properties/texture_size" }
        cornea: { $ref: "#/components/schemas/FileOptions/properties/cornea" }
        scale: { $ref: "#/components/schemas/FileOptions/properties/scale" }
    FileOptions:
      type: object
      additionalProperties: false
      description: |
        Download options. Each field may also be sent at the top level of the request. Defaults are the file the build made.
        `texture`, `normal_map`, `texture_size`: glb, obj, fbx. `cornea`: glb (`none` also obj and fbx). `scale`: glb, obj,
        fbx, stl, ply. The MetaHuman ZIP takes none (always PNG, DirectX normal map, 4K, template scale).
      properties:
        texture:
          type: string
          enum: [jpg, png, none]
          default: jpg
          description: "jpg: colour maps JPEG, normal / roughness / specular maps PNG. png: every map PNG. none: no maps; materials keep each map's average colour and roughness."
        normal_map:
          type: string
          enum: [opengl, directx]
          default: opengl
          description: "Normal-map convention: opengl (green up, glTF's) or directx (green down)."
        texture_size:
          type: string
          enum: [4k, 2k, 1k]
          default: 4k
          description: "Largest map size. 4k is as built (skin 4096, teeth 2048, eyes 1024)."
        cornea:
          type: string
          enum: [blend, transmission, none]
          default: blend
          description: "blend: a 5 % blended shell with KHR_materials_specular (Blender, any viewer). transmission: KHR_materials_transmission + KHR_materials_ior (three.js, model-viewer, Babylon.js, Cycles). none: no corneas."
        scale:
          type: string
          enum: [template, iris]
          default: template
          description: "template: life size at MetaHuman's default adult head size (not measured). iris: the geometry multiplied by the size estimate from the iris in the main photo (GLB extras faceninja_scale.iris_estimate)."
    FileVariant:
      type: object
      required: [object, id, format, arkit, variant, options, status]
      properties:
        object: { type: string, const: file_variant }
        id: { type: string, examples: [fv_Yw8u2nQd0e1KxR5tL7aB] }
        format: { $ref: "#/components/schemas/Format" }
        arkit: { type: boolean }
        variant: { type: string, description: "The option set, e.g. png-directx-2k-blend-template." }
        options: { $ref: "#/components/schemas/FileOptions" }
        status: { type: string, enum: [preparing], description: "Ask again after retry_after seconds (also in Retry-After)." }
        retry_after: { type: integer }
    FileLink:
      type: object
      required: [object, format, arkit, url, expires_at, filename]
      properties:
        object: { type: string, const: file_link }
        format: { $ref: "#/components/schemas/FileKind" }
        arkit: { type: boolean, description: "Whether the file carries ARKit blendshapes." }
        url: { type: string, format: uri, description: "Fetch it without credentials. Valid for 60 seconds from the moment it was issued." }
        expires_at: { type: string, format: date-time }
        bytes: { type: [integer, "null"] }
        sha256: { type: [string, "null"] }
        filename: { type: string, description: "The name the download is saved under." }
        options: { $ref: "#/components/schemas/FileOptions" }
        variant: { type: string, description: "With download options: the option set this file was made with." }

    WebhookEventType:
      type: string
      enum: [head.completed, head.failed, head.reference_ready]

    WebhookEnvelope:
      type: object
      required: [id, type, created_at, data]
      properties:
        id: { type: string, description: "Identifies the event. Unchanged on retries, so use it to ignore duplicates.", examples: [evt_m5T7a6gYAg4n6O3y] }
        type: { $ref: "#/components/schemas/WebhookEventType" }
        created_at: { type: string, format: date-time }
        data: { }

    HeadCompletedEvent:
      allOf:
        - $ref: "#/components/schemas/WebhookEnvelope"
        - type: object
          properties:
            type: { const: head.completed }
            data: { $ref: "#/components/schemas/Head" }

    HeadFailedEvent:
      allOf:
        - $ref: "#/components/schemas/WebhookEnvelope"
        - type: object
          properties:
            type: { const: head.failed }
            data: { $ref: "#/components/schemas/Head" }

    HeadReferenceReadyEvent:
      allOf:
        - $ref: "#/components/schemas/WebhookEnvelope"
        - type: object
          properties:
            type: { const: head.reference_ready }
            data:
              type: object
              required: [id, status]
              properties:
                id: { type: string, description: "The head." }
                status: { type: string, const: queued }

    CreateWebhookRequest:
      type: object
      required: [url]
      additionalProperties: false
      properties:
        url:
          type: string
          format: uri
          description: A public https URL without credentials. Addresses on private networks and `localhost` are refused.
        events:
          type: array
          items: { $ref: "#/components/schemas/WebhookEventType" }
          default: [head.completed, head.failed]

    WebhookEndpoint:
      type: object
      properties:
        id: { type: string, examples: [whe_fpE30bX6l7OxMGLg] }
        object: { type: string, const: webhook_endpoint }
        url: { type: string, format: uri }
        events:
          type: array
          items: { $ref: "#/components/schemas/WebhookEventType" }
        created_at: { type: string, format: date-time }

    WebhookEndpointCreated:
      allOf:
        - $ref: "#/components/schemas/WebhookEndpoint"
        - type: object
          required: [secret]
          properties:
            secret: { type: string, description: "The signing key, `whsec_…`. Shown once. Use it as the HMAC key as is." }
            note: { type: string }

    WebhookDelivery:
      type: object
      description: One attempt series of one event to one URL.
      properties:
        id: { type: string, examples: [whd_LK1bhfjGy47V0er1] }
        endpoint_id: { type: [string, "null"], description: "`null` for a delivery to a per-head `webhook_url`." }
        head_id: { type: string }
        event: { $ref: "#/components/schemas/WebhookEventType" }
        attempts: { type: integer }
        last_status: { type: [integer, "null"], description: "HTTP status of the latest attempt; `null` if it never connected." }
        last_error: { type: [string, "null"] }
        delivered_at: { type: [string, "null"], format: date-time }
        failed_at: { type: [string, "null"], format: date-time, description: "Set when every attempt has been used up." }
        next_attempt_at: { type: [string, "null"], format: date-time }
        created_at: { type: string, format: date-time }

    WebhookEndpointList:
      type: object
      properties:
        object: { type: string, const: list }
        data:
          type: array
          items: { $ref: "#/components/schemas/WebhookEndpoint" }
        recent_deliveries:
          type: array
          description: The latest 20 deliveries across all endpoints, newest first.
          items: { $ref: "#/components/schemas/WebhookDelivery" }
