Skip to content

Printify API

How to bulk upload products with the Printify API

Printify's published API documents no bulk-create endpoint. As of September 2026 it shows one image per upload request and one product per create request. A bulk upload is therefore a loop that makes both calls for each design. The loop must stay inside Printify's rate limits and never resend a timed-out create, which may already exist in the shop.

Updated

1 per callimage uploaded or product created
600 a minuterequests, counted per account
200 per 30 minproduct publish calls

The API

What the Printify API gives you for bulk creation

With a personal access token, the API manages the shops in one Printify account. A bulk run needs the eight calls below, and none of them accepts more than one image or product. The documentation describes no sandbox. It also describes no idempotency key, the usual way to make a resent create safe.

JobCallLimit it counts against
List your shopsGET /v1/shops.jsonGlobal
Find the blank (blueprint)GET /v1/catalog/blueprints.jsonCatalog
Find its print providersGET /v1/catalog/blueprints/{id}/print_providers.jsonCatalog
Get colors, sizes and print areasGET /v1/catalog/blueprints/{id}/print_providers/{id}/variants.jsonCatalog
Upload one imagePOST /v1/uploads/images.jsonGlobal
Create one productPOST /v1/shops/{shop_id}/products.jsonGlobal, plus a daily limit on product creation
List products, 50 per pageGET /v1/shops/{shop_id}/products.jsonGlobal
Publish one productPOST /v1/shops/{shop_id}/products/{id}/publish.jsonPublishing

Whether Printify can do this without code is covered in can you bulk upload to Printify. This page is for writing the loop yourself.

Rate limits

What are the Printify API rate limits?

Printify allows 600 requests a minute across the whole API. The limit is counted per account, not per token, so a second token adds no capacity. The API usage guidelines add four more rules:

  • Catalog endpoints: 100 requests a minute, inside the global 600.
  • The product publishing endpoint: 200 requests per 30 minutes.
  • Product creation and mockup generation: an extra daily limit. Printify does not publish the number and asks heavy users to open a support ticket.
  • Error responses may not exceed 5% of your total requests.

Going over a limit returns 429 Too Many Requests. The 5% rule is the one a bulk job breaks, because a loop that retries 429s fast turns one violation into many.

So the script paces itself before Printify has to refuse it. Each limit gets its own clock: one request every 120 ms, 650 ms for catalog calls and 9.1 s for publishing. That works out to 500 a minute, 92 a minute and 197 per 30 minutes.

Before you start

What you need

  • Node 18 or later. The script uses no packages.
  • A personal access token, which is Printify's API key. The Printify API key guide shows where to create one. It needs the scopes shops.read, catalog.read, products.read, products.write and uploads.write.
  • A shop to test in. Printify documents no sandbox, so add a second store and choose API as its channel, as step 5 of Printify's token instructions describes. A product there reaches no storefront. Skip the publish step in that store: publishing there locks each product until your own code reports the result, as step 11 explains.
  • A designs folder of PNG or JPEG files, one per product. Each file name becomes the start of its SKUs, uppercased with every run of other characters turned into a hyphen. Names must still differ after that, so x-y and x_y clash, and the script refuses the folder before sending anything. Names must also differ from designs already in the shop.

The script is 253 lines in nine blocks. Paste the blocks into one file, bulk-create.mjs, in the order they appear.

Step 1

Get your API token and shop ID

Printify shows a token once, right after you generate it, and it is valid for one year. Every request sends it as a bearer token. Every request must also send a User-Agent header naming your client or app.

export PRINTIFY_API_TOKEN="<your token>"

curl https://api.printify.com/v1/shops.json \
  --header "Authorization: Bearer $PRINTIFY_API_TOKEN" \
  --header "User-Agent: bulk-create-script/1.0"

The response lists each shop's id, title and sales_channel. Note the ID of your test shop.

Step 2

Find the blueprint, provider and variant IDs

A blueprint is the blank product, such as one brand's hoodie. A print provider is the company that prints it. Each provider offers its own variants of the blueprint, so look the IDs up for your own blank rather than copying them from an example.

# Every blueprint (product type). Find your blank's id, title and brand.
curl https://api.printify.com/v1/catalog/blueprints.json \
  --header "Authorization: Bearer $PRINTIFY_API_TOKEN" \
  --header "User-Agent: bulk-create-script/1.0"

# The print providers that fulfill that blueprint.
curl https://api.printify.com/v1/catalog/blueprints/<blueprint id>/print_providers.json \
  --header "Authorization: Bearer $PRINTIFY_API_TOKEN" \
  --header "User-Agent: bulk-create-script/1.0"

# Its variants from one provider: color, size and print areas.
curl https://api.printify.com/v1/catalog/blueprints/<blueprint id>/print_providers/<provider id>/variants.json \
  --header "Authorization: Bearer $PRINTIFY_API_TOKEN" \
  --header "User-Agent: bulk-create-script/1.0"

Each variant has an options object and a list of placeholders. In the documentation's example the options are color and size. Check one of yours, because the script matches those names exactly. The placeholder position values, such as front, are the print areas you can use.

export PRINTIFY_SHOP_ID=<shop id>
export BLUEPRINT_ID=<blueprint id>
export PROVIDER_ID=<provider id>

Step 3

Set up the script

Every design gets the same blank, colors, sizes, prices and placement. Prices are integer cents, as the variant properties require. The example charges more for 2XL. Replace the description with your own; the title comes from each file name.

// bulk-create.mjs: one Printify product per image in ./designs. Node 18 or later.
import { readdir, readFile, stat, writeFile } from "node:fs/promises";
import path from "node:path";

const API = "https://api.printify.com/v1";
const TOKEN = process.env.PRINTIFY_API_TOKEN;
const SHOP_ID = process.env.PRINTIFY_SHOP_ID;

// The product setup every design shares.
const BLUEPRINT_ID = Number(process.env.BLUEPRINT_ID);
const PROVIDER_ID = Number(process.env.PROVIDER_ID);
const COLORS = ["Black", "White"];
const SIZES = ["S", "M", "L", "XL", "2XL"];
const PRICE_CENTS = { default: 2499, "2XL": 2799 };
const POSITION = "front";
const DESCRIPTION = "<p>Replace with your product description.</p>";

const DESIGNS_DIR = "./designs";
const PROGRESS_FILE = "./progress.json";
// Files over 5 MB are uploaded by URL: host a copy of each at this base URL.
const PUBLIC_BASE_URL = process.env.PUBLIC_BASE_URL;

const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

Step 4

Pace every request and retry only what is safe

Every call goes through one function, which waits for its category's clock before sending. A response that takes over 60 seconds counts as a timeout.

After a network error or a 5xx, reads and uploads are retried up to three times. A 429 is retried after a wait, even for a create, because Printify refused the request rather than doing it. A create that times out or gets a 5xx is not resent. Printify's status code notes advise retrying a 503 with backoff. That is safe for a read and can duplicate a product on a create.

Before each request the function checks the error rate. Once 40 requests have gone out, it stops the run if more than 5% failed. Waiting for 40 keeps one early failure from ending a small run.

// One clock per documented limit. Catalog calls also count toward the global 600.
// 120 ms = 500 a minute (limit 600). 650 ms = 92 a minute (catalog limit 100).
// 9.1 s = 197 per 30 minutes (publish limit 200).
function gate(intervalMs) {
  let nextAt = 0;
  return async () => {
    const now = Date.now();
    const startAt = Math.max(now, nextAt);
    nextAt = startAt + intervalMs;
    await sleep(startAt - now);
  };
}
const gates = { general: gate(120), catalog: gate(650), publish: gate(9_100) };

const stats = { requests: 0, errors: 0 };
class AmbiguousCreate extends Error {}

async function printify(method, url, { body, category = "general", isCreate = false } = {}) {
  for (let attempt = 1; ; attempt++) {
    // Printify asks that error responses stay under 5% of all requests.
    if (stats.requests >= 40 && stats.errors / stats.requests > 0.05) {
      throw new Error(`Stopped: ${stats.errors} of ${stats.requests} requests failed.`);
    }
    await gates[category]();
    stats.requests++;

    let res;
    try {
      res = await fetch(API + url, {
        method,
        headers: {
          Authorization: `Bearer ${TOKEN}`,
          "User-Agent": "bulk-create-script/1.0",
          "Content-Type": "application/json",
        },
        body: body && JSON.stringify(body),
        signal: AbortSignal.timeout(60_000),
      });
    } catch (error) {
      stats.errors++;
      // The request may have reached Printify. A create is never resent blind.
      if (isCreate) throw new AmbiguousCreate(error.message);
      if (attempt === 4) throw error;
      await sleep(1_000 * 2 ** attempt);
      continue;
    }

    const text = await res.text();
    if (res.ok) return text ? JSON.parse(text) : null;
    stats.errors++;
    // A 429 means Printify refused the request, so even a create is safe to resend.
    if (res.status === 429 && attempt < 4) {
      await sleep(15_000 * attempt);
      continue;
    }
    if (res.status >= 500) {
      if (isCreate) throw new AmbiguousCreate(`${res.status} ${text}`);
      if (attempt < 4) {
        await sleep(1_000 * 2 ** attempt);
        continue;
      }
    }
    throw new Error(`${method} ${url} failed with ${res.status}: ${text}`);
  }
}

Step 5

Choose the variants from the catalog

The variants call returns only variants in stock unless you add show-out-of-stock=1. The script stops before uploading anything if no variant matches, or if a variant lacks the print position. It also stops above 100 variants, the most Printify allows on one product.

async function loadVariants() {
  const { variants } = await printify(
    "GET",
    `/catalog/blueprints/${BLUEPRINT_ID}/print_providers/${PROVIDER_ID}/variants.json`,
    { category: "catalog" },
  );
  const chosen = variants.filter(
    (v) => COLORS.includes(v.options.color) && SIZES.includes(v.options.size),
  );
  if (chosen.length === 0) throw new Error("No catalog variant matches COLORS and SIZES.");
  if (chosen.length > 100) throw new Error(`${chosen.length} variants; Printify allows 100.`);
  const noArea = chosen.filter((v) => !v.placeholders.some((p) => p.position === POSITION));
  if (noArea.length) throw new Error(`${noArea.length} variants have no "${POSITION}" area.`);
  return chosen;
}

Step 6

Upload an image with the Printify API

POST /v1/uploads/images.json takes one image. The body is file_name plus either contents, the file as base64, or url, a public link Printify downloads. The response's id is what the product refers to.

Printify's upload documentation recommends a URL for files over 5 MB. It also says base64 uploads above that size may stop being supported. The script sends small files as base64 and larger ones as a URL under PUBLIC_BASE_URL, where you host a copy.

A retried upload is harmless. At worst it leaves an unused copy in the media library.

async function uploadImage(file) {
  const fileName = path.basename(file);
  const { size } = await stat(file);
  // Printify recommends URL uploads above 5 MB and plans to drop base64 above that size.
  if (size > 5 * 1024 * 1024 && !PUBLIC_BASE_URL) {
    throw new Error(`${fileName} is over 5 MB. Set PUBLIC_BASE_URL to upload it by URL.`);
  }
  const body =
    size > 5 * 1024 * 1024
      ? { file_name: fileName, url: `${PUBLIC_BASE_URL}/${encodeURIComponent(fileName)}` }
      : { file_name: fileName, contents: (await readFile(file)).toString("base64") };
  const image = await printify("POST", "/uploads/images.json", { body });
  return image.id;
}

Step 7

Create a product with the Printify API

Create a new product needs a title, description, blueprint, provider, variants and print areas. Each variant carries its catalog id, a price in cents and is_enabled. The print area lists the variant IDs and places the image by x, y, scale and angle.

x: 0.5, y: 0.5 centers the image, and scale: 1 makes it as wide as the print area. The image positioning section explains the coordinate system.

sku is optional, and Printify generates one if you leave it out. Set your own anyway. A SKU you chose is the one field you know before the create answers, and the next step depends on it. The script builds each SKU from the file name, color and size, and refuses a product whose SKUs collide.

const code = (s) =>
  s.toUpperCase().replace(/[^A-Z0-9]+/g, "-").replace(/^-+|-+$/g, "");
const skuFor = (design, v) => `${code(design)}-${code(v.options.color)}-${code(v.options.size)}`;

function productBody(design, imageId, variants) {
  const skus = variants.map((v) => skuFor(design, v));
  if (new Set(skus).size !== skus.length) throw new Error(`Duplicate SKU in ${design}.`);
  return {
    title: design.replace(/[-_]+/g, " "),
    description: DESCRIPTION,
    blueprint_id: BLUEPRINT_ID,
    print_provider_id: PROVIDER_ID,
    variants: variants.map((v, i) => ({
      id: v.id,
      sku: skus[i],
      price: PRICE_CENTS[v.options.size] ?? PRICE_CENTS.default,
      is_enabled: true,
    })),
    print_areas: [
      {
        variant_ids: variants.map((v) => v.id),
        placeholders: [
          { position: POSITION, images: [{ id: imageId, x: 0.5, y: 0.5, scale: 1, angle: 0 }] },
        ],
      },
    ],
  };
}

Step 8

Handle a create that timed out

When a create times out, the product may or may not exist. The script waits 30 seconds, then lists the shop and looks for the design's first SKU. Exactly one match means the create landed, and the script records it. Anything else holds the design as needs_review and moves on.

A held design is never retried automatically. After checking the shop, set its status in progress.json to pending to try again, or to created with the product ID.

async function listAllProducts() {
  const products = [];
  for (let page = 1; ; page++) {
    const res = await printify("GET", `/shops/${SHOP_ID}/products.json?limit=50&page=${page}`);
    products.push(...res.data);
    if (page >= res.last_page) return { products, total: res.total };
  }
}

async function findBySku(sku) {
  const { products } = await listAllProducts();
  const matches = products.filter((p) => p.variants.some((v) => v.sku === sku));
  return matches.length === 1 ? matches[0] : null;
}

Step 9

Run the batch and resume it

progress.json records each design's image ID, first SKU and status after every step. A rerun skips finished designs and reuses uploaded images, so a crash or a closed terminal loses nothing already done. Created products stay unpublished until you publish them.

Each design is marked needs_review just before its create is sent. A design whose create was in flight when the run stopped is therefore held, like a timeout. A definite refusal, such as a 400, puts the design back to pending and stops the run.

async function loadProgress() {
  try {
    return JSON.parse(await readFile(PROGRESS_FILE, "utf8"));
  } catch {
    return {};
  }
}

async function create() {
  const progress = await loadProgress();
  const save = () => writeFile(PROGRESS_FILE, JSON.stringify(progress, null, 2));
  const files = (await readdir(DESIGNS_DIR)).filter((f) => /\.(png|jpe?g)$/i.test(f));
  const names = files.map((f) => path.parse(f).name);
  const clash = names.find((n, i) => names.findIndex((m) => code(m) === code(n)) !== i);
  if (clash) throw new Error(`"${clash}" makes the same SKUs as another file. Rename one.`);
  const variants = await loadVariants();

  for (const file of files) {
    const design = path.parse(file).name;
    const entry = (progress[design] ??= { status: "pending" });
    if (entry.status !== "pending") continue;

    entry.imageId ??= await uploadImage(path.join(DESIGNS_DIR, file));
    await save();

    const body = productBody(design, entry.imageId, variants);
    // Held until Printify answers, so a run killed mid-create never resends it.
    Object.assign(entry, { firstSku: body.variants[0].sku, status: "needs_review" });
    await save();
    try {
      const product = await printify("POST", `/shops/${SHOP_ID}/products.json`, {
        body,
        isCreate: true,
      });
      Object.assign(entry, { status: "created", productId: product.id });
    } catch (error) {
      if (!(error instanceof AmbiguousCreate)) {
        entry.status = "pending"; // a definite refusal: nothing was created
        await save();
        throw error;
      }
      await sleep(30_000); // give a slow create time to land before looking for it
      const found = await findBySku(entry.firstSku);
      if (found) Object.assign(entry, { status: "created", productId: found.id });
      else Object.assign(entry, { status: "needs_review", error: error.message });
    }
    await save();
    console.log(design, entry.status, entry.productId ?? "");
  }
}
node bulk-create.mjs create

Step 10

Verify: list the shop's products and count them

The product list returns at most 50 products a page, with last_page and total. The check pages through the whole shop and compares its count with total. For each design, it then counts the products carrying that design's first SKU.

A created or published design should appear once. A pending or held design should not appear at all. A line marked FIX is a duplicate, a lost create, or a held create that did land.

async function verify() {
  const progress = await loadProgress();
  const { products, total } = await listAllProducts();
  console.log(`Listed ${products.length} products; Printify reports ${total}.`);
  for (const [design, entry] of Object.entries(progress)) {
    const sku = entry.firstSku ?? "(none)";
    const hits = products.filter((p) => p.variants.some((v) => v.sku === sku));
    const expected = ["created", "published"].includes(entry.status) ? 1 : 0;
    const ok = hits.length === expected;
    console.log(`${ok ? "ok  " : "FIX "} ${design}: ${entry.status}, ${hits.length} in shop`);
  }
}
node bulk-create.mjs verify

We ran the script against a local stand-in for these endpoints, with the timeouts shortened. The shop already held 60 products. One create answered after the timeout and was found by its SKU. Another answered 503 and was held. The check printed:

Listed 63 products; Printify reports 63.
ok   a-design: created, 1 in shop
ok   b-design: created, 1 in shop
ok   c-design: needs_review, 0 in shop
ok   d-design: created, 1 in shop

Step 11

Publish when you are ready

Publish a product sends it to the sales channel connected to the shop. The body chooses which fields to push. At one call every 9.1 seconds, 50 products take about eight minutes.

On a custom store, such as an API test store, Printify documents that publish only triggers the product:publish:started event for your own integration. Printify locks a product during publishing. Its publishing_succeeded.json and publishing_failed.json endpoints are what remove that lock. The script below does not call them, so run this step only against a shop connected to a real sales channel.

async function publish() {
  const progress = await loadProgress();
  for (const [design, entry] of Object.entries(progress)) {
    if (entry.status !== "created") continue;
    await printify("POST", `/shops/${SHOP_ID}/products/${entry.productId}/publish.json`, {
      category: "publish",
      body: {
        title: true,
        description: true,
        images: true,
        variants: true,
        tags: true,
        keyFeatures: true,
        shipping_template: true,
      },
    });
    entry.status = "published";
    await writeFile(PROGRESS_FILE, JSON.stringify(progress, null, 2));
    console.log(design, "published");
  }
}

const command = process.argv[2] ?? "create";
await { create, verify, publish }[command]();
node bulk-create.mjs publish

Questions

Printify API FAQ

Does the Printify API have a bulk upload endpoint?

Not as of September 2026. The documentation shows one image per upload call and one product per create call. The list endpoints return up to 100 uploads or 50 products per page, but those are reads.

How do I clear out images a failed run left in the media library?

Send POST /v1/uploads/{image_id}/archive.json for each one. The script records each design's image ID in progress.json, including designs that never became products.

Why does a create fail with “Image has low quality”?

Printify validates print resolution when a product is created or updated and answers 400 when it fails. Use a larger file or a smaller scale. Each failure also counts toward the 5% error limit.

How do I change products after the batch is created?

Send PUT /v1/shops/{shop_id}/products/{product_id}.json with the fields to change. A product is locked while it publishes, and a locked product cannot be updated until publishing finishes.

Where Productify fits

Or skip the code

Productify runs this loop behind a review screen. Its request clocks match the ones above. A timed-out create is matched by SKU or held for review, never resent.

You set the product up once, from Printify's catalog or from a product already in your shop. Each upload takes up to 50 designs, and you review every proposed product before anything is created. Products arrive unpublished. You pay per product created, with no subscription.

Write the code instead if you need what Productify does not do. That includes all-over print products, several shops in one run, or a different blank for each design in one batch.

Create the batch without writing the loop.

Add up to 50 designs to one product setup, and review each product before it is created in Printify.