FurniOpsDocs

Bulk exports

Export your whole catalogue, or everything changed since a time, as gzip JSON Lines files, then download and decompress them.

A bulk export writes the catalogue your key can see to files in the background, so you can load tens of thousands of products in a few downloads instead of a long paging session. Use it to fill a new copy of the catalogue, or to catch one up after a long gap, then follow the change feed for every change after it.

Each line of an export file is one product, with its variants and offers, exactly as GET /v1/products returns it under your licences, in your key's mode.

What you need

A plan that includes bulk exports, and a key with exports:write and products:read to start one (exports:read to follow it). Of the member roles, owners, admins, developers and analysts hold all three.

1. Start an export

curl https://api.furniops.com/v1/exports \
  -H "Authorization: Bearer $FURNIOPS_API_KEY" \
  -H "Idempotency-Key: $(uuidgen)" \
  -H "Content-Type: application/json" \
  -d '{"kind": "products", "filters": {"updated_since": "2026-09-01T00:00:00Z"}}'

It answers 202 Accepted at once, with the queued export and its URL in Location:

{
  "id": "exp_034XqBaL2mRLTZPXyRbaUm",
  "object": "export",
  "kind": "products",
  "status": "pending",
  "livemode": true,
  "filters": { "updated_since": "2026-09-01T00:00:00.000Z" },
  "rows": 0,
  "files": null,
  "error": null,
  "created_at": "2026-10-04T10:00:00.000Z",
  "completed_at": null,
  "expires_at": null
}
FieldMeaning
kindWhat to export. products is the only kind: each product with its variants and offers.
filters.updated_sinceOptional. Only products that changed at or after this time (UTC), counting changes to their variants and offers. Leave it out to export every product.
export.ts
// Keep the key with your job: retrying the same request, even from another process, answers the
// export it started instead of starting a second one.
const started = await furniops.exports.create(
  { kind: "products", filters: { updated_since: "2026-09-01T00:00:00Z" } },
  { idempotencyKey: `export-${randomUUID()}` },
);
console.log(started.id, started.status); // "exp_…", "pending"

An export is a POST, so send an Idempotency-Key: a retry with the same key answers the export it already started instead of starting another. The SDK sends one for you; choose your own to make a retry from another process safe too.

2. Wait for it

An export moves through these statuses:

StatusMeaning
pendingQueued.
runningWriting its files. rows counts the products written so far.
succeededDone: files lists its files.
failedIt could not be completed; error says so. Start a new one.
expiredIts files were removed, 7 days after it succeeded.

Either poll GET /v1/exports/{id} every 10 to 30 seconds:

export.ts
let current: Export = started;
while (current.status === "pending" || current.status === "running") {
  await new Promise((resolve) => setTimeout(resolve, 15_000));
  current = await furniops.exports.get(started.id);
}
if (current.status !== "succeeded") throw new Error(current.error ?? `export ${current.status}`);

Or subscribe a webhook endpoint to export.completed, sent when an export succeeds or fails:

{
  "id": "evt_034XqBaL2mRLTZPXyRbaUp",
  "type": "export.completed",
  "spec_version": "1.0",
  "source": "furniops/backend",
  "subject": "exp_034XqBaL2mRLTZPXyRbaUm",
  "time": "2026-10-04T10:03:41Z",
  "tenant_id": "org_034XqBaL2mRLTZPXyRbaUj",
  "version": 1,
  "schema_version": 1,
  "correlation_id": null,
  "causation_id": null,
  "livemode": true,
  "data": {
    "export": "exp_034XqBaL2mRLTZPXyRbaUm",
    "kind": "products",
    "status": "succeeded",
    "rows": 61250
  }
}

The event carries no files: read the export for its links. Only webhooks deliver it (the change feed and the stream do not), to endpoints of the export's mode subscribed to export.completed or to *.

webhook.ts
// `export.completed` reaches only webhook endpoints (the change feed does not carry it), and only
// those subscribed to it or to `*`. The SDK verifies it like any event, and `WebhookEvent` covers
// it: narrowing on `type` gives its data (`subject` is the export's ID).
const event: WebhookEvent = await furniops.webhooks.verify(raw, headers, secrets);
if (event.type === "export.completed" && event.data.status === "succeeded")
  await jobs.add("download-export", { id: event.data.export }); // then GET it for the links

3. Download the files

Once it has succeeded, GET /v1/exports/{id} lists its files in order:

{
  "id": "exp_034XqBaL2mRLTZPXyRbaUm",
  "object": "export",
  "kind": "products",
  "status": "succeeded",
  "livemode": true,
  "filters": { "updated_since": "2026-09-01T00:00:00.000Z" },
  "rows": 61250,
  "files": [
    {
      "dataset": "products",
      "url": "https://storage.example/exports/0199ae4c-6f3a-7d2e-9b1c-5e8f2a7d4c10/01a0f2b9-8c58-7ba3-bf2d-f4206b6564cc/part-0001.jsonl.gz?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Expires=900&X-Amz-Signature=5d1f",
      "rows": 50000,
      "bytes": 38214077
    },
    {
      "dataset": "products",
      "url": "https://storage.example/exports/0199ae4c-6f3a-7d2e-9b1c-5e8f2a7d4c10/01a0f2b9-8c58-7ba3-bf2d-f4206b6564cc/part-0002.jsonl.gz?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Expires=900&X-Amz-Signature=9a7c",
      "rows": 11250,
      "bytes": 8601932
    }
  ],
  "error": null,
  "created_at": "2026-10-04T10:00:00.000Z",
  "completed_at": "2026-10-04T10:03:41.000Z",
  "expires_at": "2026-10-11T10:03:41.000Z"
}
FieldMeaning
files[].urlA download link, valid for 15 minutes from the read that made it. Read the export again for fresh links.
files[].rowsProducts in the file: at most 50,000. A larger export is split into parts.
files[].bytesThe file's size, compressed.
expires_atWhen the files are removed: 7 days after it succeeded.

An export with no matching products succeeds with an empty files list. GET /v1/exports lists your exports, newest first, with cursor pagination; it shows files only on GET /v1/exports/{id}.

Do not send your key to the link

The link is signed for that one file. Fetch it without an Authorization header: the storage behind it refuses a request that carries a second credential, and your API key has no business leaving api.furniops.com.

The file format

Each file is gzip-compressed JSON Lines: UTF-8 text, one JSON product per line, each line ending in a newline. Read it as a stream, a line at a time, rather than parsing it whole.

From a shell, with curl, gunzip and jq:

URL=$(curl -s https://api.furniops.com/v1/exports/exp_034XqBaL2mRLTZPXyRbaUm \
  -H "Authorization: Bearer $FURNIOPS_API_KEY" | jq -r '.files[0].url')

curl -s -o part-0001.jsonl.gz "$URL"            # no Authorization header
gunzip -c part-0001.jsonl.gz | wc -l           # one product per line
gunzip -c part-0001.jsonl.gz | jq -c '{id, title: .data.title, version}'

In TypeScript, with nothing but fetch and DecompressionStream, product by product:

export-files.ts
/** Every product in one export file, streamed: nothing holds the whole file in memory. */
export async function* readExportFile(url: string): AsyncGenerator<Product> {
  // No Authorization header: the link carries its own signature, and storage refuses a second one.
  const response = await fetch(url);
  if (!response.ok || response.body === null)
    throw new Error(`export file: HTTP ${response.status}`); // 403 once the link has expired
  const text = response.body
    .pipeThrough(new DecompressionStream("gzip"))
    .pipeThrough(new TextDecoderStream());
  let rest = "";
  for await (const chunk of text) {
    const lines = (rest + chunk).split("\n");
    rest = lines.pop() ?? ""; // a line cut across chunks waits for the next one
    for (const line of lines) if (line !== "") yield JSON.parse(line) as Product;
  }
  if (rest !== "") yield JSON.parse(rest) as Product;
}

Fetch a fresh link before each file, so a slow consumer never starts on an expired one:

export.ts
// Links are valid for 15 minutes from the read that made them: read the export again before each
// file, so a slow consumer never starts on an expired link.
for (let part = 0; part < (current.files?.length ?? 0); part++) {
  const fresh = await furniops.exports.get(current.id);
  const file = fresh.files?.[part];
  if (file === undefined) break; // expired: the files were removed after 7 days
  for await (const product of readExportFile(file.url))
    await store.upsertProduct(product.id, product.version, product);
}

Or save a file as it is, and decompress it later:

export-files.ts
/** Saves one export file as it is (gzip JSON Lines), to decompress later. */
export async function saveExportFile(url: string, path: string): Promise<void> {
  const response = await fetch(url);
  if (!response.ok || response.body === null)
    throw new Error(`export file: HTTP ${response.status}`);
  await pipeline(response.body, createWriteStream(path));
}

Loading an export into a copy that stays in sync

An export holds what the catalogue shows while it runs, read page by page, not a frozen snapshot: a product that changes during the export can appear in either version. Pair it with the change feed, as in Sync a catalogue:

  1. Read the change feed once (GET /v1/changes?limit=1) and keep its cursor.
  2. Start the export, wait for it, and load every file, storing each product with its version.
  3. Follow the feed from the cursor you kept, applying each event only when its version is newer than yours. Every change made during the export is replayed; none is missed.

Limits

LimitValueWhen it is reached
Open exports3 pending or running, per mode409 conflict. A test export never holds a live one back. Wait for one to end.
Download links15 minutes from each readThe link answers 403. Read the export again.
Files kept7 days after it succeededThe export becomes expired and files is null. Start a new one.
Products per file50,000A larger export is split into more files.
Exported rowsYour plan's monthly export_rows quota, in live mode429 quota_exceeded when starting. See Usage and quotas.
Starting exportsThe export rate class, budgeted per minute429 rate_limited.

Under heavy load, starting an export is the first request FurniOps sheds, with 503 overloaded and a short Retry-After. The products an export writes are counted once, when it succeeds; a failed export counts nothing. FurniOps retries a failing export itself before it ends it failed.

Refusals

AnswerWhyWhat to do
403 feature_unavailableYour plan does not include bulk exports.Page GET /v1/products instead, or change the plan. See Plans and features.
403 permission_deniedThe key lacks exports:write.Use a key that holds it.
403 insufficient_scopeThe key holds exports:write but not products:read: an export reads products for you.Add products:read to the key.
409 conflictThree exports are already open in this mode.Wait for export.completed.
429 quota_exceededThis month's export_rows quota is spent.Wait for the new month (Retry-After).
export.ts
try {
  await furniops.exports.create({ kind: "products" });
} catch (error) {
  if (error instanceof PermissionError && error.code === "feature_unavailable") {
    // The plan has no bulk exports: page GET /v1/products instead.
  } else if (error instanceof PermissionError && error.code === "insufficient_scope") {
    // The key holds exports:write but not products:read.
  } else if (error instanceof ConflictError && error.code === "conflict") {
    // Three exports are already pending or running in this mode: wait for export.completed.
  } else if (error instanceof RateLimitError && error.code === "quota_exceeded") {
    // This month's export_rows quota is spent; Retry-After counts down to the new month (UTC).
    await jobs.add("export", {}, { delay: error.retryAfterMs ?? 3_600_000 });
  } else throw error;
}

In test mode

A test key exports your sandbox catalogue, and the export says livemode: false. Test exports are never capped by a quota and have their own limit of three open exports. An export, its files and its export.completed event belong to the mode that started it; the other mode's key gets 404 for it.

On this page