> ## 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: Bulk-Creating Positions with a Script

> Create many positions at once from a script: upload one shared intro/outro video, then loop over an array of positions and questions.

## When to use this

Use this when you need to **spin up many positions at once** — for example, migrating job templates from another ATS, or setting up a batch of roles for a hiring event — without recording a unique video for every position or question.

The pattern: upload **one intro video and one outro video**, reuse their `mediaId` across every position the script creates, and drive the rest (name, tags, questions, per-position or per-question overrides) from a plain JavaScript array you edit at the top of the script.

<Tip>
  This assumes a **shared** intro/outro across all positions. If you want a unique video per position or per question, that's usually easier to record and upload from the dashboard — see the note on [adding a per-question video](#adding-a-per-question-video) at the end of this page if you want to extend the script to do that too.
</Tip>

## Before you start

* **Node.js 18+** (for native `fetch`) — confirm with `node -v`.
* Your [Hireflix API Key](/tech/quickstart), exported as `HIREFLIX_API_KEY`.
* Two local video files to use as the shared intro and outro (`.mp4`, `.mov`, or `.webm`, max 150 MB each).
* Read [Creating a Position](/tech/features/positions/creating-position) first if any of `createPosition`, `mediaId`, or the reserve-upload flow is unfamiliar — this page builds directly on it.

## Step 1: Upload the shared intro & outro videos

This script reserves an upload for an intro video and an outro video, PUTs both files to their presigned URLs, and prints the two `mediaId`s you'll paste into the script in Step 2.

Save this as `upload_shared_media.js`.

```javascript upload_shared_media.js theme={null}
#!/usr/bin/env node
/**
 * Upload one shared intro video and one shared outro video, to reuse
 * across every position created by bulk_create_positions.js.
 *
 * Requirements: Node.js 18+ (for native fetch)
 * Usage:
 *   export HIREFLIX_API_KEY="<your-api-key>"
 *   node upload_shared_media.js <intro-video-path> <outro-video-path>
 */

import fs from "fs";
import path from "path";

const API_URL = "https://api.hireflix.com/me";
const API_KEY = process.env.HIREFLIX_API_KEY;
const [introPath, outroPath] = process.argv.slice(2);

if (!API_KEY) {
  console.error("Missing HIREFLIX_API_KEY environment variable.");
  process.exit(1);
}

if (!introPath || !outroPath) {
  console.error("Usage: node upload_shared_media.js <intro-video-path> <outro-video-path>");
  process.exit(1);
}

const HEADERS = {
  "Content-Type": "application/json",
  "X-Api-Key": API_KEY,
};

const MIME_TYPES = {
  ".mp4": "video/mp4",
  ".mov": "video/quicktime",
  ".webm": "video/webm",
};

function mimeTypeFor(filePath) {
  if (!fs.existsSync(filePath)) {
    throw new Error(`File not found: ${filePath}`);
  }
  const ext = path.extname(filePath).toLowerCase();
  const mimeType = MIME_TYPES[ext];
  if (!mimeType) {
    throw new Error(`Unsupported file extension "${ext}". Use .mp4, .mov, or .webm.`);
  }
  return mimeType;
}

async function gql(query, variables = {}) {
  const res = await fetch(API_URL, {
    method: "POST",
    headers: HEADERS,
    body: JSON.stringify({ query, variables }),
  });

  if (!res.ok) {
    throw new Error(`HTTP ${res.status}: ${await res.text()}`);
  }

  const data = await res.json();
  if (data.errors) {
    throw new Error(JSON.stringify(data.errors, null, 2));
  }
  return data.data;
}

async function uploadFile(presignedPutUrl, filePath, mimeType) {
  const res = await fetch(presignedPutUrl, {
    method: "PUT",
    headers: {
      "Content-Type": mimeType,
      // Required: the presigned URL signs this header, so S3 rejects the
      // upload if it's missing or different.
      "x-amz-acl": "bucket-owner-full-control",
    },
    body: fs.readFileSync(filePath),
  });

  if (!res.ok) {
    throw new Error(`Upload failed: HTTP ${res.status}`);
  }
}

async function reserveAndUploadIntro(filePath) {
  const mimeType = mimeTypeFor(filePath);
  const sizeBytes = fs.statSync(filePath).size;

  const query = `
    mutation ReserveIntro($input: MutationReservePositionIntroVideoUploadInput!) {
      reservePositionIntroVideoUpload(input: $input) {
        __typename
        ... on MutationReservePositionIntroVideoUploadSuccess {
          data { mediaId presignedPutUrl }
        }
        ... on ValidationError { validationMessage: message fieldErrors { path message } }
        ... on MediaFileTooLargeError { tooLargeMessage: message }
        ... on MediaFileUploadMimeTypeNotSupportedError { mimeTypeMessage: message }
      }
    }
  `;

  const data = await gql(query, { input: { mimeType, sizeBytes } });
  const result = data.reservePositionIntroVideoUpload;

  if (result.__typename !== "MutationReservePositionIntroVideoUploadSuccess") {
    throw new Error(`Intro reservation failed: ${JSON.stringify(result)}`);
  }

  await uploadFile(result.data.presignedPutUrl, filePath, mimeType);
  return result.data.mediaId;
}

async function reserveAndUploadOutro(filePath) {
  const mimeType = mimeTypeFor(filePath);
  const sizeBytes = fs.statSync(filePath).size;

  const query = `
    mutation ReserveOutro($input: MutationReservePositionOutroVideoUploadInput!) {
      reservePositionOutroVideoUpload(input: $input) {
        __typename
        ... on MutationReservePositionOutroVideoUploadSuccess {
          data { mediaId presignedPutUrl }
        }
        ... on ValidationError { validationMessage: message fieldErrors { path message } }
        ... on MediaFileTooLargeError { tooLargeMessage: message }
        ... on MediaFileUploadMimeTypeNotSupportedError { mimeTypeMessage: message }
      }
    }
  `;

  const data = await gql(query, { input: { mimeType, sizeBytes } });
  const result = data.reservePositionOutroVideoUpload;

  if (result.__typename !== "MutationReservePositionOutroVideoUploadSuccess") {
    throw new Error(`Outro reservation failed: ${JSON.stringify(result)}`);
  }

  await uploadFile(result.data.presignedPutUrl, filePath, mimeType);
  return result.data.mediaId;
}

async function main() {
  console.log("Uploading intro video...");
  const introMediaId = await reserveAndUploadIntro(introPath);
  console.log(`Intro mediaId: ${introMediaId}`);

  console.log("Uploading outro video...");
  const outroMediaId = await reserveAndUploadOutro(outroPath);
  console.log(`Outro mediaId: ${outroMediaId}`);

  console.log("\nPaste these into bulk_create_positions.js:");
  console.log(`  INTRO_MEDIA_ID = "${introMediaId}"`);
  console.log(`  OUTRO_MEDIA_ID = "${outroMediaId}"`);
}

main().catch((err) => {
  console.error(err);
  process.exit(1);
});
```

<Steps>
  <Step title="Create the script file">
    Never run a script before? Open a **plain-text editor** — not Word or Google Docs, since those add hidden formatting that breaks the file. A free option that works on Mac and Windows is [VS Code](https://code.visualstudio.com/); Notepad (Windows) or TextEdit (Mac, switch to *Format → Make Plain Text* first) also work.

    Copy the full script above, paste it into a new file, and save it as `upload_shared_media.js` in a folder you'll remember, e.g. your Desktop.
  </Step>

  <Step title="Open a terminal">
    * **Mac:** press `Cmd + Space`, type `Terminal`, press Enter.
    * **Windows:** press the Start key, type `Command Prompt` (or `PowerShell`), press Enter.

    Then move into the folder where you saved the script, e.g.:

    ```bash theme={null}
    cd Desktop
    ```
  </Step>

  <Step title="Set your API key">
    ```bash theme={null}
    export HIREFLIX_API_KEY="<your-api-key>"
    ```
  </Step>

  <Step title="Run the script">
    Pass the path to your intro video, then your outro video:

    ```bash theme={null}
    node upload_shared_media.js ./intro.mp4 ./outro.mp4
    ```

    It prints something like:

    ```
    Uploading intro video...
    Intro mediaId: 68d56e0aa3d3c03c50f10421

    Uploading outro video...
    Outro mediaId: 68d56e0aa3d3c03c50f10499

    Paste these into bulk_create_positions.js:
      INTRO_MEDIA_ID = "68d56e0aa3d3c03c50f10421"
      OUTRO_MEDIA_ID = "68d56e0aa3d3c03c50f10499"
    ```

    Keep these two IDs — you'll paste them into the next script.
  </Step>
</Steps>

## Step 2: Bulk-create the positions

This script defines a `POSITIONS` array — one entry per position, each with its own questions — and loops over it, calling `createPosition` once per entry. Every position reuses the same intro/outro `mediaId` from Step 1.

Before creating anything, it runs `validatePositions()` to check every position name, tag, and question title against Hireflix's length limits (see `LIMITS` in the script). This catches mistakes like an over-length question title **up front** — otherwise you'd only find out after the 1st, 5th, or 20th position was already created, and now have a mix of created and not-yet-created positions to sort out.

The example array below shows the full range of what's possible:

* The **first position** sends no `answerFormat` at all for either question — `video: {}` is passed, so both questions inherit whatever default timing/retakes are currently configured in Hireflix, and the dashboard shows them as using the defaults, not custom settings.
* The **second position** overrides the answer format for **all of its questions** (`answerFormat` set at the position level).
* One question in the second position goes further and sets its **own** `answerFormat`, overriding the position-level value — showing the full override chain in one place.

<Warning>
  Don't hardcode the account/position default numbers (e.g. `maxDurationSeconds: 120`) into the script "just to be explicit." Hireflix flags a question as having **custom settings** in the dashboard as soon as it receives an explicit `answerFormat`, even if the values happen to match the current defaults — and if a real default later changes in Hireflix, a script carrying stale hardcoded numbers would silently diverge from it. Only set `answerFormat` for questions that are genuinely meant to differ; leave it out (`video: {}`) everywhere else.
</Warning>

Save this as `bulk_create_positions.js`.

```javascript bulk_create_positions.js theme={null}
#!/usr/bin/env node
/**
 * Bulk-create positions from a plain JS array, reusing one shared
 * intro/outro video (from upload_shared_media.js) across all of them.
 *
 * Requirements: Node.js 18+ (for native fetch)
 * Usage:
 *   export HIREFLIX_API_KEY="<your-api-key>"
 *   node bulk_create_positions.js
 */

const API_URL = "https://api.hireflix.com/me";
const API_KEY = process.env.HIREFLIX_API_KEY;

if (!API_KEY) {
  console.error("Missing HIREFLIX_API_KEY environment variable.");
  process.exit(1);
}

// Paste the mediaIds printed by upload_shared_media.js.
const INTRO_MEDIA_ID = "<intro-mediaId-from-step-1>";
const OUTRO_MEDIA_ID = "<outro-mediaId-from-step-1>";

// Shown on the outro screen after every position's interview.
const OUTRO_TITLE = "Thanks for applying!";
const OUTRO_SUBTITLE = "We'll be in touch soon.";

// --- Edit this array to define the positions you want to create. ---
const POSITIONS = [
  {
    name: "Junior Frontend Developer",
    tags: ["Engineering"],
    // No answerFormat anywhere in this position — every question inherits
    // Hireflix's current defaults and shows up as "default", not "custom".
    questions: [
      { title: "Tell us about yourself" },
      { title: "Why do you want to work here?" },
    ],
  },
  {
    name: "Senior DX Researcher",
    tags: ["DX", "DevRel"],
    // Applies to every question in this position that doesn't set its own answerFormat.
    answerFormat: {
      maxDurationSeconds: 180,
      timeToThinkSeconds: 45,
      numAllowedRetakes: 1,
    },
    questions: [
      { title: "Describe a project you're proud of" },
      {
        title: "Explain a complex technical concept",
        // Fully custom override for this one question only.
        answerFormat: {
          maxDurationSeconds: 300,
          timeToThinkSeconds: 60,
          numAllowedRetakes: -1,
        },
      },
    ],
  },
];
// ---------------------------------------------------------------------

const HEADERS = {
  "Content-Type": "application/json",
  "X-Api-Key": API_KEY,
};

async function gql(query, variables = {}) {
  const res = await fetch(API_URL, {
    method: "POST",
    headers: HEADERS,
    body: JSON.stringify({ query, variables }),
  });

  if (!res.ok) {
    throw new Error(`HTTP ${res.status}: ${await res.text()}`);
  }

  const data = await res.json();
  if (data.errors) {
    throw new Error(JSON.stringify(data.errors, null, 2));
  }
  return data.data;
}

// Hireflix's field length limits — kept here so validation and the actual
// mutation can't drift apart.
const LIMITS = {
  positionName: 110,
  maxTags: 5,
  tag: 25,
  questionTitle: 40,
  outroTitle: 100,
  outroSubtitle: 500,
};

function validatePositions() {
  const errors = [];

  if (OUTRO_TITLE.length < 1 || OUTRO_TITLE.length > LIMITS.outroTitle) {
    errors.push(`OUTRO_TITLE is ${OUTRO_TITLE.length} characters (must be 1-${LIMITS.outroTitle}): "${OUTRO_TITLE}"`);
  }
  if (OUTRO_SUBTITLE.length < 1 || OUTRO_SUBTITLE.length > LIMITS.outroSubtitle) {
    errors.push(`OUTRO_SUBTITLE is ${OUTRO_SUBTITLE.length} characters (must be 1-${LIMITS.outroSubtitle}): "${OUTRO_SUBTITLE}"`);
  }

  POSITIONS.forEach((position, posIndex) => {
    const label = position.name ? `"${position.name}"` : `POSITIONS[${posIndex}]`;

    if (!position.name || position.name.length > LIMITS.positionName) {
      errors.push(`${label}: name is ${position.name?.length ?? 0} characters (must be 1-${LIMITS.positionName})`);
    }

    const tags = position.tags || [];
    if (tags.length > LIMITS.maxTags) {
      errors.push(`${label}: has ${tags.length} tags (max ${LIMITS.maxTags})`);
    }
    tags.forEach((tag) => {
      if (tag.length < 1 || tag.length > LIMITS.tag) {
        errors.push(`${label}: tag "${tag}" is ${tag.length} characters (must be 1-${LIMITS.tag})`);
      }
    });

    (position.questions || []).forEach((question, qIndex) => {
      const title = question.title || "";
      if (title.length < 1 || title.length > LIMITS.questionTitle) {
        errors.push(
          `${label}: question ${qIndex + 1} title is ${title.length} characters (must be 1-${LIMITS.questionTitle}): "${title}"`
        );
      }
    });
  });

  if (errors.length > 0) {
    console.error(`Found ${errors.length} problem(s) in POSITIONS — fix these before creating anything:\n`);
    errors.forEach((e) => console.error(`  - ${e}`));
    process.exit(1);
  }
}

function buildSteps(position) {
  return position.questions.map((question) => ({
    QA: {
      title: question.title,
      answerFormat: {
        // {} means "use whatever default is currently set in Hireflix" —
        // only questions with a real override send explicit values.
        video: question.answerFormat || position.answerFormat || {},
      },
    },
  }));
}

async function createPosition(position) {
  const query = `
    mutation CreatePosition($input: MutationCreatePositionInput!) {
      createPosition(input: $input) {
        __typename
        ... on MutationCreatePositionSuccess {
          data { id name }
        }
        ... on ValidationError { validationMessage: message fieldErrors { path message } }
        ... on MediaNotFoundError { notFoundMessage: message }
      }
    }
  `;

  const input = {
    name: position.name,
    tags: position.tags || [],
    introVideo: { mediaId: INTRO_MEDIA_ID },
    outro: {
      title: OUTRO_TITLE,
      subtitle: OUTRO_SUBTITLE,
      video: { mediaId: OUTRO_MEDIA_ID },
    },
    steps: buildSteps(position),
  };

  const data = await gql(query, { input });
  const result = data.createPosition;

  if (result.__typename !== "MutationCreatePositionSuccess") {
    throw new Error(`Failed to create "${position.name}": ${JSON.stringify(result)}`);
  }

  return result.data;
}

async function main() {
  validatePositions();

  console.log(`Creating ${POSITIONS.length} position(s)...\n`);

  for (const position of POSITIONS) {
    console.log(`Creating "${position.name}"...`);
    const created = await createPosition(position);
    console.log(`  -> id: ${created.id}`);
  }

  console.log("\nDone. Verify at https://admin.hireflix.com/jobs");
}

main().catch((err) => {
  console.error(err);
  process.exit(1);
});
```

<Steps>
  <Step title="Paste your mediaIds">
    Open `bulk_create_positions.js` and replace `INTRO_MEDIA_ID` and `OUTRO_MEDIA_ID` with the two values printed in Step 1.
  </Step>

  <Step title="Customize the POSITIONS array">
    Edit the `POSITIONS` array to match the roles you actually want to create — add or remove positions, change `tags`, add questions, and only set `answerFormat` at the position or question level where you actually need something other than Hireflix's current defaults.
  </Step>

  <Step title="Set your API key">
    ```bash theme={null}
    export HIREFLIX_API_KEY="<your-api-key>"
    ```

    Skip this if you already exported it in Step 1 and you're still in the same terminal session.
  </Step>

  <Step title="Run the script">
    ```bash theme={null}
    node bulk_create_positions.js
    ```

    It validates `POSITIONS` first, then logs each position as it's created:

    ```
    Creating 2 position(s)...

    Creating "Junior Frontend Developer"...
      -> id: 68d56e0aa3d3c03c50f10501
    Creating "Senior DX Researcher"...
      -> id: 68d56e0aa3d3c03c50f10502

    Done. Verify at https://admin.hireflix.com/jobs
    ```

    If a title, tag, or name is too long, it stops **before creating anything** and lists every problem it found, instead of failing midway through the array:

    ```
    Found 1 problem(s) in POSITIONS — fix these before creating anything:

      - "Senior DX Researcher": question 1 title is 41 characters (must be 1-40): "Walk us through a project you're proud of"
    ```
  </Step>
</Steps>

<Warning>
  `validatePositions()` only catches length problems it knows about (`LIMITS`) — it can't catch everything the API might reject, like an expired or wrong-target `mediaId` (`MediaNotFoundError`). If `createPosition` itself fails partway through the loop, the script stops at that **first** failed position rather than skipping it and continuing — check the error message, fix the offending entry in `POSITIONS`, and re-run. Positions created before the failure already exist; re-running the script will create duplicates of those unless you remove them from the array first.
</Warning>

<Tip>
  Verify what was created by opening [admin.hireflix.com/jobs](https://admin.hireflix.com/jobs) and searching for each position by name.
</Tip>

## Adding a per-question video

The script above only reuses the shared **intro/outro** — it doesn't attach a video to individual questions. If you want that too, reserve a video per question with `reservePositionQAVideoContentUpload`, upload it the same way as Step 1, and add the resulting `mediaId` to that question's `questionFormat`:

```javascript theme={null}
QA: {
  title: question.title,
  questionFormat: {
    video: { mediaId: question.videoMediaId } // reserved separately per question
  },
  answerFormat: {
    video: question.answerFormat || position.answerFormat || {},
  },
}
```

See the **QA question video** tab in [Adding video content to a position](/tech/features/positions/creating-position#adding-video-content-to-a-position) for the full reserve-and-upload mutation this relies on.

## Key reminders

* The intro and outro `mediaId`s are uploaded **once** and reused across every position in `POSITIONS` — don't call `upload_shared_media.js` again unless you want to change the shared videos.
* `answerFormat` resolves in this order: **question-level** → **position-level** → `{}` (Hireflix's current default). Only set it where a question or position genuinely needs to differ — an explicit `answerFormat` marks that question as "custom" in the dashboard even when the values match the defaults, and hardcoded numbers in the script can silently drift from the real defaults if those ever change in Hireflix.
* Unsure what the current default `maxDurationSeconds`, `timeToThinkSeconds`, or `numAllowedRetakes` are, or what each setting means for candidates? See [Tailoring the Interview](/user/building-the-interview/tailoring-the-interview).
* `validatePositions()` runs before any position is created, checking name/tag/question-title lengths against `LIMITS`. Extend it if you add other fields to `POSITIONS` that carry their own length limits.
* The script is otherwise fail-fast — a `createPosition` failure not caught by validation still stops the whole run. Re-running after a partial failure will recreate the positions that already succeeded, so trim `POSITIONS` down to the ones that failed before re-running.

<Card title="Learn next?" color="#5863ff" icon="arrow-right" horizontal href="/tech/features/positions/fetching-positions">
  Now that you've bulk-created positions, let's learn how to fetch positions — handy for confirming what the script created, or looking up IDs for later updates.
</Card>
