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.
This guide builds the most common integration: a storefront, ERP or warehouse that keeps its own copy of the catalogue and stays in step as prices, stock and content change. The pattern is the same whatever you store it in:
- Mark where the change feed is now.
- Backfill the current state with
GET /v1/products. - Follow the change feed from the mark, forever, applying each event by version.
- Optionally, add webhooks to hear about changes within seconds, keeping the feed as the safety net.
What you need
An API key with products:read and events:read (the Change feed reader template has both),
and somewhere to keep a cursor that survives restarts.
1. Mark the feed, then backfill
Read the feed once before the backfill and keep its cursor. Every change committed during the backfill is after that cursor, so the follow step replays it; nothing falls between the two.
// 1. Note where the feed is now, before the backfill, so no change made during it is missed.
const head = await furniops.changes.list({ limit: 1 });
// 2. Copy the current state.
for await (const product of furniops.products.list({ limit: 100 })) {
await store.upsertProduct(product.id, product.version, product);
}The same with curl:
# The mark: an empty or one-event page still answers the cursor to start from.
curl "https://api.furniops.com/v1/changes?limit=1" \
-H "Authorization: Bearer $FURNIOPS_API_KEY"
# The backfill, 100 products a page; pass each page's next_cursor as starting_after.
curl "https://api.furniops.com/v1/products?limit=100" \
-H "Authorization: Bearer $FURNIOPS_API_KEY"A product arrives with its variants and their offers, so one page is everything you need for
those products. Store each object with its version.
For a large catalogue, a bulk export can replace the backfill: the same products as gzip JSON Lines files, in a few downloads. Mark the feed before you start it, exactly as here.
2. Follow the feed
// 3. Follow the feed from the saved cursor, forever. `wait` long-polls for up to 25 s.
let cursor = (await store.getCursor()) ?? head.cursor ?? undefined;
for (;;) {
try {
const page = await furniops.changes.list({ cursor, limit: 500, wait: 25, payload: "fat" });
for (const event of page.data) await store.applyEvent(event); // idempotent: by version
if (page.cursor !== null) {
cursor = page.cursor;
await store.setCursor(cursor); // after the events are applied, never before
}
} catch (error) {
// Events are kept 30 days: an older cursor answers 400. Backfill again, then carry on.
if (error instanceof ValidationError && cursor !== undefined) {
cursor = undefined;
continue;
}
throw error;
}
}The loop has four rules, and each one prevents a real bug:
| Rule | Why |
|---|---|
| Save the cursor after applying the page's events. | A crash between the two replays the page instead of skipping it. |
Apply an event only when its version is newer than yours. | Replays and duplicates then change nothing. |
Use wait=25. | An idle loop waits on the server instead of polling, and hears of a change at once. |
On 400 for the cursor, backfill again. | Events are kept 30 days; a copy left behind longer must start over. |
Applying an event
A fat payload carries the object as of the event's version, so the simplest store replaces its
row with data.object when the version is newer, and handles the lifecycle events on their own:
// The feed's and the stream's events are one type: ChangeEvent. A webhook brings the same events,
// and also the organisation's own (WebhookEvent): pass a webhook's catalogue events here.
export async function applyEvent(event: ChangeEvent): Promise<void> {
switch (event.type) {
case "product.merged":
case "variant.merged":
// The object was folded into another: point your references at the survivor.
await db.redirect(event.data.id, event.data.merged_into);
return;
case "product.discontinued":
await db.markDiscontinued(event.data.id, event.data.version);
return;
default: {
// A fat payload's object as of this version. Absent with thin payloads, and when a later
// version in the same change follows; source health events carry no catalogue object.
const object = "object" in event.data ? event.data.object : undefined;
if (typeof object !== "object") return;
await db.upsertIfNewer(event.subject, object.version, object); // older versions: no-op
}
}
}product.discontinued: the product is gone from its sources. Hide it or mark it, rather than deleting it at once.product.merged/variant.merged: the object was folded intomerged_into. Point your references at the survivor.product.unmerged/variant.unmerged: a merge was undone; the event carries every field, so rebuild the object from it.
With thin payloads, apply data.fields instead: each has the path of a changed field and its
new value. Thin events are smaller; fat ones save the merging and any read back.
Catching up after a long outage
When the cursor is older than 30 days, start again with updated_since instead of a full
backfill: only products that changed (counting their variants and offers) since that time are
listed.
curl "https://api.furniops.com/v1/products?updated_since=2026-09-01T00:00:00Z&limit=100" \
-H "Authorization: Bearer $FURNIOPS_API_KEY"Take a new mark first, as in step 1.
3. Add webhooks for speed
The feed with wait=25 already hears of a change within moments. Webhooks help when you would
rather be called than keep a loop running, or when many workers must react. Run both:
- the webhook handler applies events as they arrive, by version;
- the feed loop keeps running (more slowly, if you like) and applies anything a webhook missed. Because both apply by version, an event seen twice changes nothing.
See Your first webhook and Handle webhooks securely.
Narrowing what you sync
typeson the feed keeps only the event types you list:?types=offer.changed,offer.createdfor a price and stock mirror.- Webhook endpoints can filter by source and by category (
filters.sources,filters.categories). - For network catalogue data, your licences decide which fields reach you.
Checklist
Before you go live, check that:
- The cursor is stored durably and saved only after its events are applied.
- Every write is conditional on a newer
version. - The loop handles a
400for an expired cursor by backfilling. - Merges, unmerges and discontinuations are handled.
- Unknown event types and unknown fields are ignored, not fatal (new ones are added without a new version).
RateLimitErrorand network errors are retried with backoff (the SDK does this for you).
Proposals
Every change an AI agent wants to make, with the proof FurniOps ran on it. What a proposal holds, how it is proved and rated low or high risk, its statuses, accepting and rejecting it, and the webhooks that announce it.
Bulk exports
Export your whole catalogue, or everything changed since a time, as gzip JSON Lines files, then download and decompress them.