Shared building blocks · 5 of 15
Sync a full catalog
Make a store's Snapcaster listings match a catalog file or export, and retry each write safely.
When your system hands you a whole catalog, such as a CSV file or a nightly export, a sync makes the store’s Snapcaster listings match it, on Path A and Path B.
A rerun or a retry is safe when two things hold.
The run’s asOf is no later than the moment your system counted the stock in the file, so it cannot release the hold of a sale the count did not see.
Every row is valid and the file lists every SKU the store sells, because the sync deletes each live listing the file leaves out.
What the sync does
Section titled “What the sync does”- Read the whole file, refuse it if any row is invalid, and choose one
asOffor it. - Export the store’s live listings with
GET /listings, followingnextCursoruntil it is null. - Send one
POST /listings/bulkwith anupsertitem for every row in the file and adeleteitem for every live SKU the file no longer has, all with thatasOf. - Read every per-item result in the response.
Snapcaster stores the accepted items of one request together, so a request that failed or lost its response stored all of them or none. A request holds at most 1,000 items, so a larger catalog takes several requests, and if the run stops between them, the next run finishes the job.
A row with quantity 0 keeps its listing, with no stock. A SKU missing from the file is deleted and leaves browse, so a truncated or malformed file deletes listings the store still sells. If one row is invalid, write nothing, deletes included.
Choose asOf for a file
Section titled “Choose asOf for a file”Each item carries asOf, the UTC instant when your system counted the stock in the file.
Pick it from the first source you have.
- A count time that your export records, in a column, a header, or the file name.
- The time just before your sync requests a live export, if your system counts the stock as it answers.
- The file’s last-modified time, if the file arrives with no time at all.
Choose asOf once per file and send it with every item and every retry of that file.
A rerun of an unchanged file then answers noop for every item, and a new export of the same stock has a new count time, so its items answer applied.
Never use the time the sync runs.
A later asOf can release a sale’s hold while the file’s count still includes the sold card, and the card can sell twice.
The last-modified time can be later than the count by as long as the export took to write, so prefer the first two sources.
A URL that serves a file written earlier counts as a file, not a live export.
An item with an older asOf than the snapshot Snapcaster holds for its SKU is rejected with STALE_SNAPSHOT, and nothing from it is stored.
That happens when you replay an older file after a newer one, or when your system pushed that SKU after it counted the file.
Do not resend the item with a newer asOf, because that labels an old count as current.
Count the stock again and send it with the new count’s time.
Deletes follow the same rule, so an older file cannot bring back a listing that a newer write deleted.
Run the sample
Section titled “Run the sample”The sample needs Node.js 18.3 or later and no packages.
It reads a CSV with the header sku_id,price,quantity, where price is the unit price in Canadian dollars, such as 4.50, which the sample converts to integer cents.
An optional fourth column, counted_at, carries the count time, the same value on every row.
If your file names a column differently, map it in the sample’s RENAMED_COLUMNS, such as { unit_price: 'price' }, rather than editing the file, because an edited copy gets a new last-modified time.
import { readFile, stat } from 'node:fs/promises';
import { setTimeout as sleep } from 'node:timers/promises';
import { parseArgs } from 'node:util';
const BASE_URL = process.env.SNAPCASTER_BASE_URL ?? 'https://api-sandbox.snapcaster.ca/api/v1/marketplace';
const API_KEY = process.env.SNAPCASTER_API_KEY;
const { values: options, positionals } = parseArgs({
options: { 'counted-at': { type: 'string' }, 'live-export': { type: 'boolean' } },
allowPositionals: true,
});
const CATALOG = positionals[0] ?? 'catalog.csv';
const BATCH_SIZE = 1000;
const MAX_ATTEMPTS = 5;
const REQUEST_TIMEOUT_MS = 30_000;
const MAX_BACKOFF_MS = 30_000;
const TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/;
const COUNT_TIME_RULE = 'a past time with Z or an offset, such as 2026-10-26T05:20:00Z';
const COLUMNS = {
sku_id: { valid: (cell) => /^[1-9]\d*$/.test(cell), rule: 'a positive whole number' },
price: { valid: (cell) => /^\d+(\.\d{1,2})?$/.test(cell), rule: 'dollars such as 4.50, not negative' },
quantity: { valid: (cell) => /^\d+$/.test(cell), rule: 'a whole number, 0 or more' },
counted_at: { valid: isCountTime, rule: COUNT_TIME_RULE },
};
const HEADERS = ['sku_id,price,quantity', 'sku_id,price,quantity,counted_at'];
const RENAMED_COLUMNS = {};
class Retryable extends Error {
constructor(message, waitMs) {
super(message);
this.waitMs = waitMs;
}
}
function isCountTime(text) {
return TIME.test(text) && Date.parse(text) <= Date.now();
}
async function loadCatalog(source) {
if (!/^https?:\/\//.test(source)) {
const [text, { mtime }] = await Promise.all([readFile(source, 'utf8'), stat(source)]);
return { text, modifiedAt: mtime.toISOString() };
}
const requestedAt = new Date().toISOString();
const response = await fetch(source, { signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
if (!response.ok) throw new Error(`${source} answered ${response.status}`);
const lastModified = Date.parse(response.headers.get('last-modified') ?? '');
return {
text: await response.text(),
requestedAt: options['live-export'] ? requestedAt : undefined,
modifiedAt: Number.isNaN(lastModified) ? undefined : new Date(lastModified).toISOString(),
};
}
function parseCatalog(text, name) {
const [header, ...lines] = text.trim().split(/\r?\n/);
const columns = header.split(',').map((column) => RENAMED_COLUMNS[column.trim()] ?? column.trim());
if (!HEADERS.includes(columns.join(',')))
return {
problems: [
`${name} must start with ${HEADERS.join(' or ')}, or map its other column names in RENAMED_COLUMNS`,
],
};
if (lines.length === 0)
return { problems: [`${name} has no rows; purge the store's Listings instead of syncing an empty file`] };
const problems = [];
const rows = [];
const lineOfSku = new Map();
const countTimes = new Set();
lines.forEach((line, index) => {
const at = `${name} line ${index + 2}`;
const cells = line.split(',').map((cell) => cell.trim());
if (cells.length !== columns.length) {
problems.push(`${at} has ${cells.length} cells, expected ${columns.length}: ${line}`);
return;
}
const row = Object.fromEntries(columns.map((column, i) => [column, cells[i]]));
const bad = columns.filter((column) => !COLUMNS[column].valid(row[column]));
for (const column of bad)
problems.push(
`${at}: ${column} is ${row[column] ? `"${row[column]}"` : 'empty'}, expected ${COLUMNS[column].rule}`
);
if (bad.length) return;
const skuId = Number(row.sku_id);
if (lineOfSku.has(skuId)) problems.push(`${at}: sku_id ${skuId} is already on line ${lineOfSku.get(skuId)}`);
lineOfSku.set(skuId, index + 2);
if (row.counted_at) countTimes.add(Date.parse(row.counted_at));
rows.push({ skuId, price: Math.round(Number(row.price) * 100), quantity: Number(row.quantity) });
});
if (countTimes.size > 1)
problems.push(`${name} has ${countTimes.size} different counted_at times; send one file per count`);
const [countedAt] = countTimes;
return { rows, countedAt: countedAt === undefined ? undefined : new Date(countedAt).toISOString(), problems };
}
function chooseAsOf(option, countedAt, { requestedAt, modifiedAt }) {
const asOf = option === undefined ? (countedAt ?? requestedAt) : new Date(option).toISOString();
if (asOf) return asOf;
if (!modifiedAt) {
console.error(
`${CATALOG} carries no count time and no valid Last-Modified header. ` +
'Pass --counted-at with the time the stock was counted, or --live-export if your system counts the stock as it answers.'
);
console.error('Nothing was written.');
process.exit(1);
}
console.error(
`Warning: ${CATALOG} carries no count time, so asOf is its last-modified time, ${modifiedAt}. ` +
"If the stock was counted earlier and a sale was acknowledged in between, this write releases that sale's hold " +
'while the file still counts the sold card, and the card can sell twice. ' +
'Pass --counted-at with the time the stock was counted.'
);
return modifiedAt;
}
async function send(method, path, body) {
let response;
let text;
try {
response = await fetch(`${BASE_URL}${path}`, {
method,
headers: { Authorization: `Bearer ${API_KEY}`, 'Content-Type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
});
text = await response.text();
} catch (error) {
throw new Retryable(`${method} ${path} got no response: ${error.message}`);
}
if (response.status === 429) {
const retryAfter = response.headers.get('retry-after');
throw new Retryable(
`${method} ${path} answered 429`,
retryAfter === null ? undefined : Number(retryAfter) * 1000 + Math.random() * 1000
);
}
if (response.status >= 500) {
const correlationId = response.headers.get('x-correlation-id');
throw new Retryable(`${method} ${path} answered ${response.status}, X-Correlation-ID ${correlationId}`);
}
if (!response.ok) throw new Error(`${method} ${path} answered ${response.status}: ${text}`);
return JSON.parse(text).data;
}
async function call(method, path, body) {
for (let attempt = 1; ; attempt++) {
try {
return await send(method, path, body);
} catch (error) {
if (!(error instanceof Retryable) || attempt === MAX_ATTEMPTS) throw error;
const waitMs = error.waitMs ?? Math.random() * Math.min(MAX_BACKOFF_MS, 1000 * 2 ** attempt);
console.error(`${error.message}; retrying in ${Math.ceil(waitMs / 1000)} s`);
await sleep(waitMs);
}
}
}
async function liveSkuIds() {
const skuIds = new Set();
let cursor = null;
do {
const page = await call('GET', `/listings?limit=1000${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`);
for (const listing of page.items) skuIds.add(listing.skuId);
cursor = page.nextCursor;
} while (cursor);
return skuIds;
}
if (!API_KEY) throw new Error("Set SNAPCASTER_API_KEY to the Seller's Marketplace API key");
const catalog = await loadCatalog(CATALOG);
const { rows, countedAt, problems } = parseCatalog(catalog.text, CATALOG);
const option = options['counted-at'];
if (option !== undefined && !isCountTime(option))
problems.push(`--counted-at is "${option}", expected ${COUNT_TIME_RULE}`);
else if (option !== undefined && countedAt !== undefined && Date.parse(option) !== Date.parse(countedAt))
problems.push(`--counted-at ${option} differs from the file's counted_at ${countedAt}`);
if (problems.length) {
for (const problem of problems) console.error(problem);
console.error('Nothing was written. Fix the file and run the sync again.');
process.exit(1);
}
const asOf = chooseAsOf(option, countedAt, catalog);
const inFile = new Set(rows.map((row) => row.skuId));
const absent = [...(await liveSkuIds())].filter((skuId) => !inFile.has(skuId));
const items = [
...rows.map((row) => ({ op: 'upsert', ...row, asOf })),
...absent.map((skuId) => ({ op: 'delete', skuId, asOf })),
];
const counts = {};
const rejected = [];
for (let start = 0; start < items.length; start += BATCH_SIZE) {
const { results } = await call('POST', '/listings/bulk', { items: items.slice(start, start + BATCH_SIZE) });
for (const result of results) {
counts[result.status] = (counts[result.status] ?? 0) + 1;
if (result.status === 'rejected') rejected.push(result);
}
}
console.log(JSON.stringify({ asOf, ...counts }));
for (const result of rejected) console.error(`${result.op} ${result.skuId} rejected: ${result.code} ${result.reason}`);
process.exitCode = rejected.length ? 1 : 0;Save a catalog next to it, using the sandbox catalog SKUs.
sku_id,price,quantity
14602,4.50,5
363348,3.25,2
316074,1.10,7Run it with the seller’s marketplace API key.
It calls the sandbox, and setting SNAPCASTER_BASE_URL to the production URL points it at production.
SNAPCASTER_API_KEY='mp_yourSellerCode_yourSecret' node sync.mjs catalog.csvOn a store with no listings, the first run applies every item and a second run of the same file answers noop for each.
This file carries no count time, so the sample uses its last-modified time as asOf and warns on each run.
{"asOf":"2026-10-26T05:22:42.000Z","applied":3}
{"asOf":"2026-10-26T05:22:42.000Z","noop":3}The sample takes asOf from the first of these it has, in the order of the sources above.
- The
--counted-atoption, such asnode sync.mjs --counted-at 2026-10-26T05:20:00Z catalog.csv. - The file’s
counted_atcolumn. - The time just before it fetched the export, when you pass the export’s URL with
--live-exportbecause your system counts the stock as it answers. - The export’s last-modified time, from the file or from a fetched export’s
Last-Modifiedheader, with the warning.
It refuses a fetched export with none of these, a count time in the future or without Z or an offset, and a --counted-at that disagrees with counted_at.
Before it reads or writes any listing, the sample checks that every row has no empty cell, a positive whole sku_id that appears once, a price that is not negative, and a whole quantity of 0 or more.
It also refuses a file with no rows, because syncing it would delete every listing.
A failed check prints each bad line and exits with code 1 without writing anything.
A rejected item prints its code and reason and exits with code 1.
The sample sends no productId, so a SKU missing from the canonical catalog comes back rejected with INVALID_REQUEST.
Retry a write whose response was lost
Section titled “Retry a write whose response was lost”When a listing write times out or the connection drops, send the same request again with the same body and the same asOf.
An item Snapcaster already applied answers noop and one it did not apply answers applied, so a run whose first attempt went through can print nothing but noop counts.
Listing writes take no Idempotency-Key, unlike order commands, because the asOf on each item does that job.
A retry that changes an item’s price, quantity, or externalRef but keeps its asOf is rejected with SNAPSHOT_CONFLICT.
Build the request once, before the first attempt, and resend those same items.
If the file changed in the meantime, treat it as a new file with a new asOf.
Retry numbers
Section titled “Retry numbers”The sample uses the timeout, attempt count, and backoff from Recommended timeout and backoff, and handles every other failure as What to do after each failure says.
On a 5xx it also prints the X-Correlation-ID response header to send to support.
When the attempts run out, it exits with an error, and the next run of the same file with the same asOf finishes the work without harm.
Purge only to empty a store
Section titled “Purge only to empty a store”POST /listings/purge removes every listing whose snapshot is at or before its asOf.
After that, Snapcaster rejects every write for the seller with an asOf at or before that instant, with STALE_SNAPSHOT.
Use it to take a store’s whole catalog off Snapcaster, for example when the seller stops selling through your platform.
Do not purge to start a routine sync over, because the store has no listings in browse between the purge and your next push.
Check the result
Section titled “Check the result”Read the store’s listings with GET /listings and compare quantity and price with your file.
GET /me reports how many listings are matched and browsable.
Need help?
The email includes this page and selects sandbox as the environment. Update it if you need help with production.
Never include an API key or signing secret in the message.