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
}
| Field | Meaning |
|---|---|
kind | What to export. products is the only kind: each product with its variants and offers. |
filters.updated_since | Optional. Only products that changed at or after this time (UTC), counting changes to their variants and offers. Leave it out to export every product. |
// 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:
| Status | Meaning |
|---|---|
pending | Queued. |
running | Writing its files. rows counts the products written so far. |
succeeded | Done: files lists its files. |
failed | It could not be completed; error says so. Start a new one. |
expired | Its files were removed, 7 days after it succeeded. |
Either poll GET /v1/exports/{id} every 10 to 30 seconds:
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 *.
// `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 links3. 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"
}
| Field | Meaning |
|---|---|
files[].url | A download link, valid for 15 minutes from the read that made it. Read the export again for fresh links. |
files[].rows | Products in the file: at most 50,000. A larger export is split into parts. |
files[].bytes | The file's size, compressed. |
expires_at | When 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:
/** 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:
// 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:
/** 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:
- Read the change feed once (
GET /v1/changes?limit=1) and keep itscursor. - Start the export, wait for it, and load every file, storing each product with its
version. - 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
| Limit | Value | When it is reached |
|---|---|---|
| Open exports | 3 pending or running, per mode | 409 conflict. A test export never holds a live one back. Wait for one to end. |
| Download links | 15 minutes from each read | The link answers 403. Read the export again. |
| Files kept | 7 days after it succeeded | The export becomes expired and files is null. Start a new one. |
| Products per file | 50,000 | A larger export is split into more files. |
| Exported rows | Your plan's monthly export_rows quota, in live mode | 429 quota_exceeded when starting. See Usage and quotas. |
| Starting exports | The export rate class, budgeted per minute | 429 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
| Answer | Why | What to do |
|---|---|---|
403 feature_unavailable | Your plan does not include bulk exports. | Page GET /v1/products instead, or change the plan. See Plans and features. |
403 permission_denied | The key lacks exports:write. | Use a key that holds it. |
403 insufficient_scope | The key holds exports:write but not products:read: an export reads products for you. | Add products:read to the key. |
409 conflict | Three exports are already open in this mode. | Wait for export.completed. |
429 quota_exceeded | This month's export_rows quota is spent. | Wait for the new month (Retry-After). |
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.
Sync a catalogue
Keep a complete, correct copy of your FurniOps catalogue in your own database, with a backfill, the change feed, and webhooks for speed.
React to price and stock changes
Use offer.changed to update prices and stock within seconds, tell price moves from stock moves, and check stock at the moment that matters.