> ## Documentation Index
> Fetch the complete documentation index at: https://hireflixsl.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# How-to: Adding an Image Prompt Question

> Create a question that shows candidates an image while they record their video answer, using a GraphQL multipart upload with createPosition or updatePosition.

## When to use this

An **image prompt** is a question that shows the candidate an image while they record their video answer. Use it when you want candidates to react to something visual: a system architecture diagram, a chart, a design mockup, or a product photo. See [Image Prompt Questions](/user/building-the-interview/image-prompt-questions) for how it looks in the dashboard.

Through the API, an image prompt is a regular `QA` step with `questionFormat.image` set. The candidate still answers on video, so `answerFormat.video` is required as usual.

<Warning>
  **Send exactly one image per question.** The schema accepts 1–10 images in `questionFormat.image`, but the dashboard and the candidate experience currently support only one. If you send more, candidates only see the first image. Support for multiple images per question may come later. If you want candidates to react to several images, create a separate image prompt question for each one.
</Warning>

If you're an AI agent or LLM building a position for a user: always put **one** file in `upload` (or one ID in `mediaIds`), even though the schema description says "1-10 images". If the user asks for several images on one question, explain that only one image per question is supported for now and offer to create one image prompt question per image instead.

## Before you start

Don't forget to send your [Hireflix API Key](/tech/quickstart) in the `X-API-KEY` header to `https://api.hireflix.com/me`.

Check the image first:

* **File type** – any `image/*` MIME type: JPEG, PNG, GIF, WebP, or SVG. Anything else is rejected with `MediaFileUploadMimeTypeNotSupportedError`.
* **File size** – 10 MB max per image, which is exactly **10,000,000 bytes**. Note this is a decimal megabyte, unlike the 150 MB video limit (157,286,400 bytes). Larger files are rejected with `MediaFileUploadTooBigError`.
* **Dimensions** – no width or height limit.

## How image uploads differ from video uploads

Videos and images are uploaded in completely different ways, so don't reuse the video flow from [Creating a Position](/tech/features/positions/creating-position#adding-video-content-to-a-position).

| | Question video | Question image |
| - | - | - |
| Input field | `questionFormat.video.mediaId` | `questionFormat.image.upload` or `questionFormat.image.mediaIds` |
| How the file is sent | Reserve an upload, PUT the file to a presigned S3 URL, then pass the `mediaId` | Sent **inside the same request** as `createPosition`/`updatePosition`, as a multipart upload |
| Number of requests | 3 (reserve, PUT, create) | 1 |
| Max size | 150 MB (157,286,400 bytes) | 10 MB (10,000,000 bytes) |

## How a multipart upload works

A normal GraphQL request is a JSON body. JSON can't carry a binary file, so the API follows the [GraphQL multipart request spec](https://github.com/jaydenseric/graphql-multipart-request-spec): instead of JSON, you send a `multipart/form-data` request (the same format a browser uses for an HTML form with a file input) made of three parts.

<Steps>
  <Step title="operations: the mutation, with a placeholder for the file">
    A form field named `operations` that holds the JSON you'd normally send as the request body: `query` plus `variables`. Declare the file as a variable of type `Upload!` and set its value to `null`. The `null` is a placeholder that the server replaces with the uploaded file.

    ```json theme={null}
    {
      "query": "mutation CreatePositionWithImagePrompt($image: Upload!) { createPosition(input: { ... questionFormat: { image: { upload: [$image] } } ... }) { ... } }",
      "variables": { "image": null }
    }
    ```
  </Step>

  <Step title="map: which file goes where">
    A form field named `map` that tells the server which file part fills which variable. The key is the name of the file part (`"0"`), and the value is a list of paths into `operations`.

    ```json theme={null}
    { "0": ["variables.image"] }
    ```

    This reads as: "put the file from form field `0` into `variables.image`". The path matches the variable name you declared in step 1. The mutation wraps that variable in a list itself (`upload: [$image]`), so the path doesn't need a list index.
  </Step>

  <Step title="The file itself">
    A file part whose name matches the key in `map` (here `0`), containing the raw image bytes and an `image/*` content type.
  </Step>
</Steps>

Put together, the request body looks like this on the wire:

```text theme={null}
POST https://api.hireflix.com/me
X-API-KEY: <your-api-key>
Content-Type: multipart/form-data; boundary=----hireflix

------hireflix
Content-Disposition: form-data; name="operations"

{"query":"mutation CreatePositionWithImagePrompt($image: Upload!) { ... }","variables":{"image":null}}
------hireflix
Content-Disposition: form-data; name="map"

{"0":["variables.image"]}
------hireflix
Content-Disposition: form-data; name="0"; filename="diagram.png"
Content-Type: image/png

<binary image data>
------hireflix--
```

You never write this by hand. Every HTTP client has a form-data helper that builds it for you, as the examples below show. But it's worth knowing what's in it, because each rule below maps to a common error.

<Warning>
  **Common pitfalls:**

  * **Order matters:** `operations` first, then `map`, then the file. Otherwise the request fails with `Misordered multipart fields; files should follow 'map'`.
  * **`operations` and `map` must be plain text fields, not file parts.** In curl, `-F 'operations=@operations.json'` sends the JSON as a *file*, which triggers the same "misordered" error. Use `-F 'operations=<operations.json'` instead (`<` reads the file's contents into a text field).
  * **Always set the file's content type explicitly** (`image/png`, `image/jpeg`, `image/webp`, ...). The server checks the content type declared on the file part, not the file contents. Some clients only guess it from the file extension: curl, for example, sends a `.webp` file as `application/octet-stream`, which is rejected with `MediaFileUploadMimeTypeNotSupportedError`.
  * **Don't set the request's `Content-Type` header yourself.** Let your HTTP client set it. It adds the `boundary=...` value that separates the parts; if you hard-code `multipart/form-data` without it, the server can't parse the body.
  * **Alias `message` when you request several error types.** The error types don't all declare `message` the same way, so requesting `message` on more than one of them fails validation with `Fields "message" conflict because they return conflicting types`. Give each one an alias, like `tooBigMessage: message`, as the examples below do.
</Warning>

## Create a position with an image prompt

This is the full mutation used in every example below. It creates a position with one image prompt question:

```graphql theme={null}
mutation CreatePositionWithImagePrompt($image: Upload!) {
  createPosition(input: {
    name: "Image Prompt Example"
    steps: [
      {
        QA: {
          title: "What do you see in this image?"
          description: "Describe your reaction to the image above."
          questionFormat: {
            image: {
              upload: [$image]
            }
          }
          answerFormat: {
            video: {}
          }
        }
      }
    ]
  }) {
    __typename
    ... on MutationCreatePositionSuccess {
      data {
        id
        name
      }
    }
    ... on ValidationError {
      validationMessage: message
      fieldErrors { path message }
    }
    ... on MediaFileUploadTooBigError { tooBigMessage: message }
    ... on MediaFileUploadMimeTypeNotSupportedError { mimeTypeMessage: message }
  }
}
```

* **questionFormat.image.upload** – the image file, passed as an `Upload!` variable. Always exactly one.
* **answerFormat.video** – required, because video is currently the only answer format. `{}` uses the position defaults; see [Creating a Position](/tech/features/positions/creating-position) for the available settings.
* **title** / **description** – same as any other question. The image is shown alongside them.

Send it with any HTTP client that supports multipart form data:

<Tabs>
  <Tab title="curl">
    Save the `operations` JSON to a file, since the query is awkward to escape inline:

    ```json operations.json theme={null}
    {
      "query": "mutation CreatePositionWithImagePrompt($image: Upload!) { createPosition(input: { name: \"Image Prompt Example\", steps: [{ QA: { title: \"What do you see in this image?\", description: \"Describe your reaction to the image above.\", questionFormat: { image: { upload: [$image] } }, answerFormat: { video: {} } } }] }) { __typename ... on MutationCreatePositionSuccess { data { id name } } ... on ValidationError { validationMessage: message fieldErrors { path message } } ... on MediaFileUploadTooBigError { tooBigMessage: message } ... on MediaFileUploadMimeTypeNotSupportedError { mimeTypeMessage: message } } }",
      "variables": { "image": null }
    }
    ```

    Then send the three parts, in order:

    ```bash theme={null}
    curl https://api.hireflix.com/me \
      -H "X-API-KEY: $HIREFLIX_API_KEY" \
      -F 'operations=<operations.json' \
      -F 'map={"0": ["variables.image"]}' \
      -F '0=@diagram.png;type=image/png'
    ```

    * `operations=<operations.json` reads the file's contents into a **text** field. Don't use `@` here.
    * `0=@diagram.png` attaches the image as a **file** part named `0`, matching the key in `map`.
    * `;type=image/png` sets the file's content type. Change it to match your image (`image/jpeg`, `image/webp`, ...).
    * `-F` makes curl send `multipart/form-data` and set the `Content-Type` header with the boundary, so don't add one yourself.
  </Tab>

  <Tab title="Node.js">
    Uses the built-in `fetch`, `FormData`, and `fs.openAsBlob` (Node.js 20+, no dependencies).

    ```javascript upload-image-prompt.mjs theme={null}
    import { openAsBlob } from "node:fs";

    const query = `
      mutation CreatePositionWithImagePrompt($image: Upload!) {
        createPosition(input: {
          name: "Image Prompt Example"
          steps: [
            {
              QA: {
                title: "What do you see in this image?"
                description: "Describe your reaction to the image above."
                questionFormat: { image: { upload: [$image] } }
                answerFormat: { video: {} }
              }
            }
          ]
        }) {
          __typename
          ... on MutationCreatePositionSuccess { data { id name } }
          ... on ValidationError { validationMessage: message fieldErrors { path message } }
          ... on MediaFileUploadTooBigError { tooBigMessage: message }
          ... on MediaFileUploadMimeTypeNotSupportedError { mimeTypeMessage: message }
        }
      }
    `;

    const form = new FormData();
    // 1. operations: the query, with the file variable set to null
    form.append("operations", JSON.stringify({ query, variables: { image: null } }));
    // 2. map: form field "0" fills variables.image
    form.append("map", JSON.stringify({ "0": ["variables.image"] }));
    // 3. the file itself, with an explicit image/* MIME type
    form.append("0", await openAsBlob("./diagram.png", { type: "image/png" }), "diagram.png");

    const response = await fetch("https://api.hireflix.com/me", {
      method: "POST",
      headers: { "X-API-KEY": process.env.HIREFLIX_API_KEY }, // no Content-Type: fetch sets it, with the boundary
      body: form,
    });

    console.log(JSON.stringify(await response.json(), null, 2));
    ```

    In the browser it's the same code, except you append the `File` from an `<input type="file">` instead of calling `openAsBlob`. A `File` already carries its MIME type.
  </Tab>

  <Tab title="Python">
    Uses the [`requests`](https://pypi.org/project/requests/) library (`pip install requests`).

    ```python upload_image_prompt.py theme={null}
    import json
    import os

    import requests

    query = """
    mutation CreatePositionWithImagePrompt($image: Upload!) {
      createPosition(input: {
        name: "Image Prompt Example"
        steps: [
          {
            QA: {
              title: "What do you see in this image?"
              description: "Describe your reaction to the image above."
              questionFormat: { image: { upload: [$image] } }
              answerFormat: { video: {} }
            }
          }
        ]
      }) {
        __typename
        ... on MutationCreatePositionSuccess { data { id name } }
        ... on ValidationError { validationMessage: message fieldErrors { path message } }
        ... on MediaFileUploadTooBigError { tooBigMessage: message }
        ... on MediaFileUploadMimeTypeNotSupportedError { mimeTypeMessage: message }
      }
    }
    """

    with open("diagram.png", "rb") as image:
        # A list keeps the parts in order: operations, map, then the file.
        # (None, ...) sends a plain text field instead of a file.
        parts = [
            ("operations", (None, json.dumps({"query": query, "variables": {"image": None}}))),
            ("map", (None, json.dumps({"0": ["variables.image"]}))),
            ("0", ("diagram.png", image, "image/png")),
        ]
        response = requests.post(
            "https://api.hireflix.com/me",
            headers={"X-API-KEY": os.environ["HIREFLIX_API_KEY"]},  # no Content-Type: requests sets it
            files=parts,
        )

    print(json.dumps(response.json(), indent=2))
    ```

    Pass all three parts through `files` as a list, rather than splitting them between `data=` and `files=`, so the order is guaranteed.
  </Tab>
</Tabs>

A successful response looks like this:

```json theme={null}
{
  "data": {
    "createPosition": {
      "__typename": "MutationCreatePositionSuccess",
      "data": {
        "id": "6ac4bdb2a350d222342ea4cd",
        "name": "Image Prompt Example"
      }
    }
  }
}
```

## Reuse an image with mediaIds

Every uploaded image gets a media ID. To use the same image in another question or position, pass that ID in `mediaIds` instead of uploading the file again. No upload means no multipart request: this is a normal JSON request.

Fetch the media ID of an image that's already on a position. Replace `<position-id>` with the ID of the position that has the image, for example the `data.id` returned when you created it:

```graphql theme={null}
query GetPositionImages {
  position(id: "<position-id>") {
    id
    stepTemplates(input: {}) {
      ... on AdminQAStep {
        id
        title
        questionFormat {
          __typename
          ... on AdminQAImageContent {
            images {
              id
              mimeType
              url
            }
          }
        }
      }
    }
  }
}
```

Image questions come back as `AdminQAImageContent`, and `images[].id` is the media ID. Video questions come back as `AdminQAVideoContent`, and text-only questions have `questionFormat: null`.

Then reference it in `mediaIds`:

```graphql theme={null}
mutation CreatePositionWithExistingImage {
  createPosition(input: {
    name: "Image Prompt Example (reused image)"
    steps: [
      {
        QA: {
          title: "Explain this diagram"
          questionFormat: {
            image: {
              mediaIds: ["<image-media-id>"]
            }
          }
          answerFormat: {
            video: {}
          }
        }
      }
    ]
  }) {
    __typename
    ... on MutationCreatePositionSuccess { data { id name } }
    ... on ValidationError { validationMessage: message fieldErrors { path message } }
    ... on MediaNotFoundError { notFoundMessage: message }
    ... on MediaMimeTypeMismatchError { mismatchMessage: message }
  }
}
```

* Use **either** `upload` **or** `mediaIds` in one question, with one image in total.
* The ID must belong to an image. A video's media ID returns `MediaMimeTypeMismatchError`.
* The 10 MB limit applies to existing media as well.

## Image questions in updatePosition

[`updatePosition`](/tech/features/positions/updating-position) accepts the same `questionFormat.image` input, with both `upload` and `mediaIds`. To add a new image question to an existing position, send the same multipart request as above, but with `updatePosition` and a `$image: Upload!` variable.

<Warning>
  `steps` in `updatePosition` **fully replaces** every question on the position. When you resend an existing image question, include its image again with `questionFormat: { image: { mediaIds: ["<image-media-id>"] } }`. If you leave out `questionFormat`, the question is saved as a text-only question and the image is removed. Fetch the current media IDs first with the [query above](#reuse-an-image-with-mediaids).
</Warning>

This example keeps an existing image question and adds a new one with a freshly uploaded image, in a single multipart request (`map` stays `{"0": ["variables.image"]}`):

```graphql theme={null}
mutation UpdatePositionImageSteps($image: Upload!) {
  updatePosition(input: {
    positionId: "<position-id>"
    steps: [
      {
        QA: {
          id: "<existing-step-id>"
          title: "Explain this diagram"
          questionFormat: { image: { mediaIds: ["<existing-image-media-id>"] } }
          answerFormat: { video: {} }
        }
      }
      {
        QA: {
          title: "What stands out in this chart?"
          questionFormat: { image: { upload: [$image] } }
          answerFormat: { video: {} }
        }
      }
    ]
  }) {
    __typename
    ... on MutationUpdatePositionSuccess { data { id } }
    ... on PositionNotFoundError { positionNotFoundMessage: message }
    ... on ValidationError { validationMessage: message fieldErrors { path message } }
    ... on MediaNotFoundError { notFoundMessage: message }
    ... on MediaFileUploadTooBigError { tooBigMessage: message }
    ... on MediaFileUploadMimeTypeNotSupportedError { mimeTypeMessage: message }
  }
}
```

## Handling the response

`createPosition` and `updatePosition` return a union type. For image questions, these are the cases to handle:

* **`MutationCreatePositionSuccess`** / **`MutationUpdatePositionSuccess`** – the position was saved; read it from `data`.
* **`ValidationError`** – one or more fields failed validation; inspect `fieldErrors` for the offending `path` and `message`.
* **`MediaFileUploadTooBigError`** – the uploaded image is over 10,000,000 bytes. For example: `File "big.png" exceeds maximum allowed size of 10000000 bytes`.
* **`MediaFileUploadMimeTypeNotSupportedError`** – the file part's content type isn't `image/*`. For example: `File "diagram.webp" has unsupported mime type "application/octet-stream". Supported types are: image/*`. If the file really is an image, set its content type explicitly (see [How a multipart upload works](#how-a-multipart-upload-works)).
* **`MediaNotFoundError`** – a media ID in `mediaIds` doesn't exist or doesn't belong to your company.
* **`MediaMimeTypeMismatchError`** – a media ID in `mediaIds` isn't an image.

A malformed multipart request (wrong part order, `operations` sent as a file) fails before GraphQL runs, and returns a plain JSON object with a `message` such as `Misordered multipart fields; files should follow 'map'` instead of a `data` object.

## Key reminders

* **One image per question.** The schema accepts up to 10, but candidates only see the first one. Use one image prompt question per image.
* **Images are uploaded inside the mutation request** as `multipart/form-data`, not through a reserve/presigned-URL flow like videos.
* **Part order:** `operations`, `map`, then the file. `operations` and `map` are text fields.
* **Set the file's content type explicitly** to an `image/*` type. Don't set the request's `Content-Type` header yourself.
* **Max 10 MB (10,000,000 bytes) per image**, any `image/*` type, no dimension limit.
* **`answerFormat.video` is required** on every image question.
* **Reuse images with `mediaIds`**, and always resend `questionFormat` for existing image questions in `updatePosition`, or the image is dropped.
* Verify the result by opening [admin.hireflix.com/jobs](https://admin.hireflix.com/jobs), finding the position by name, and previewing the interview.

<Card title="Learn next?" color="#5863ff" icon="arrow-right" horizontal href="/tech/features/positions/bulk-creating-positions">
  Need to create many positions at once? Let's learn how to bulk-create positions with a script.
</Card>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.