LockStock docs

Push API examples

Ready-to-copy clients for Node.js and PHP — batches, safe retries, job polling and full reconciliation.

Experimental

Like the push API itself, these clients may change before the general release.

Two small clients for the push API, one file each and without dependencies: Node.js 18+ (built-in fetch) and PHP 8.1+ (the curl extension, no Composer packages — fits a 1C-Bitrix module or a cron script). Copy the file into your project. Both are run against the API by our test suite on every change, so the code on this page works as shown.

What they handle for you:

  • Batches. Lists of any size are split into batches the API accepts: 1,000 products, 5,000 offers.
  • Safe retries. Every change gets one Idempotency-Key before the first attempt and keeps it on retries. If a request timed out after it reached us, the retry gets the first answer instead of applying the change twice.
  • When to retry. Network errors, timeouts, 409 (the same key is still being processed), 429 and 5xx are retried with backoff, honouring Retry-After. Any other error is thrown at once with its code and the X-Request-Id to quote to support.
  • Jobs. Writes are asynchronous; waitForJob polls until the job is succeeded, rejected or failed.

The client

lockstock-push.mjs
// LockStock catalog push API client for Node.js 18+. No dependencies: copy this file into your project.
import { randomUUID } from 'node:crypto';

const RETRYABLE = new Set([409, 429, 500, 502, 503, 504]);
const FINISHED = new Set(['succeeded', 'rejected', 'failed']);

export class LockstockPushError extends Error {
  constructor(status, body, requestId) {
    super(body?.error?.message ?? `LockStock answered HTTP ${status}`);
    this.status = status;
    this.code = body?.error?.code;
    this.requestId = requestId; // quote it when you contact support
  }
}

export class LockstockPush {
  constructor({ secretKey, apiUrl = 'https://api.lockstock.example', maxAttempts = 5 }) {
    this.secretKey = secretKey;
    this.apiUrl = apiUrl.replace(/\/$/, '');
    this.maxAttempts = maxAttempts;
  }

  /**
   * One API call with retries. The Idempotency-Key is chosen once, before the first attempt, and
   * sent again on every retry: if the first attempt did reach LockStock, the retry gets the same answer
   * instead of applying the change twice.
   */
  async request(method, path, { body, idempotencyKey } = {}) {
    const headers = { Authorization: `Bearer ${this.secretKey}` };
    if (body !== undefined) headers['Content-Type'] = 'application/json';
    if (idempotencyKey) headers['Idempotency-Key'] = idempotencyKey;

    for (let attempt = 1; ; attempt++) {
      let response;
      try {
        response = await fetch(this.apiUrl + path, {
          method,
          headers,
          body: body === undefined ? undefined : JSON.stringify(body),
          signal: AbortSignal.timeout(30_000),
        });
      } catch (error) {
        // Network error or timeout: the request may or may not have arrived. Same key, try again.
        if (attempt >= this.maxAttempts) throw error;
        await sleep(backoff(attempt));
        continue;
      }
      if (response.ok) return response.status === 204 ? null : response.json();

      const errorBody = await response.json().catch(() => null);
      // 409: the same key is still being processed. 429 and 5xx: try again later.
      if (!RETRYABLE.has(response.status) || attempt >= this.maxAttempts) {
        throw new LockstockPushError(
          response.status,
          errorBody,
          response.headers.get('x-request-id'),
        );
      }
      const retryAfter = Number(response.headers.get('retry-after'));
      await sleep(retryAfter > 0 ? retryAfter * 1000 : backoff(attempt));
    }
  }

  /** Sends `items` in batches of `size`, one Idempotency-Key per batch. Returns the job ids. */
  async sendInBatches(method, path, field, items, size) {
    const jobIds = [];
    for (let start = 0; start < items.length; start += size) {
      const batch = items.slice(start, start + size);
      const { jobId } = await this.request(method, path, {
        body: { [field]: batch },
        idempotencyKey: randomUUID(),
      });
      jobIds.push(jobId);
    }
    return jobIds;
  }

  upsertCategories(categories) {
    return this.sendInBatches('PUT', '/v1/catalog/categories', 'categories', categories, 10_000);
  }

  upsertOutlets(outlets) {
    return this.sendInBatches('PUT', '/v1/catalog/outlets', 'outlets', outlets, 1_000);
  }

  upsertProducts(products) {
    return this.sendInBatches('PUT', '/v1/catalog/products', 'products', products, 1_000);
  }

  /** Price, old price, availability and stock only: the fast path. */
  updateOffers(offers) {
    return this.sendInBatches('PATCH', '/v1/catalog/offers', 'offers', offers, 5_000);
  }

  async deleteProduct(externalId) {
    const { jobId } = await this.request(
      'DELETE',
      `/v1/catalog/products/${encodeURIComponent(externalId)}`,
      { idempotencyKey: randomUUID() },
    );
    return jobId;
  }

  /**
   * Makes the LockStock catalog match `products` exactly: products missing from the list are removed
   * from search. Batch keys are derived from the session id, so re-running a failed batch is safe.
   */
  async fullSync(products) {
    const { sessionId } = await this.request('POST', '/v1/catalog/sync-sessions');
    for (let start = 0, batch = 0; start < products.length; start += 1_000, batch++) {
      await this.request('PUT', `/v1/catalog/sync-sessions/${sessionId}/products`, {
        body: { products: products.slice(start, start + 1_000) },
        idempotencyKey: `${sessionId}:${batch}`,
      });
    }
    const { jobId } = await this.request('POST', `/v1/catalog/sync-sessions/${sessionId}/commit`, {
      idempotencyKey: `${sessionId}:commit`,
    });
    return jobId;
  }

  /** Polls a job until it finishes: `succeeded`, `rejected` or `failed`. */
  async waitForJob(jobId, { timeoutMs = 120_000 } = {}) {
    const deadline = Date.now() + timeoutMs;
    for (let attempt = 1; Date.now() < deadline; attempt++) {
      const job = await this.request('GET', `/v1/catalog/jobs/${jobId}`);
      if (FINISHED.has(job.status)) return job;
      await sleep(Math.min(500 * attempt, 5_000));
    }
    throw new Error(`LockStock job ${jobId} did not finish in ${timeoutMs / 1000} s`);
  }
}

/** 0.5, 1, 2, 4… seconds, capped at 30, with jitter so many clients do not retry in step. */
function backoff(attempt) {
  return Math.min(500 * 2 ** (attempt - 1), 30_000) * (0.5 + Math.random() / 2);
}

function sleep(ms) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}

Every example below is a complete script: save it next to the client and run it with your key. Use the secret key from the dashboard (sk_live_…; sk_test_… for a test store). It stays on your server: never put it in a page or a mobile app.

LOCKSTOCK_API_URL=https://api.lockstock.example LOCKSTOCK_SECRET_KEY=sk_test_… node products.mjs

Products and pickup points

Send pickup points before stock that refers to them. A product is replaced as a whole: send all its fields every time, because a field left out is cleared. For frequent partial changes use price and stock. updatedAt is when the product changed in your system: an update older than what we already have is skipped, so retries and late deliveries never overwrite fresher data. Prices are in major units of your store's currency.

products.mjs
import { LockstockPush } from './lockstock-push.mjs';

const lockstock = new LockstockPush({
  apiUrl: process.env.LOCKSTOCK_API_URL, // https://api.lockstock.example
  secretKey: process.env.LOCKSTOCK_SECRET_KEY, // sk_live_…: keep it on the server, never in a page
});

// Pickup points first: stock refers to them by id.
await lockstock.upsertOutlets([
  { id: 'derbent-1', name: 'Дербент, ул. Ленина 12', city: 'Дербент', hours: '9:00–20:00' },
]);

const [jobId] = await lockstock.upsertProducts([
  {
    id: 'docs-1204581',
    name: 'Перфоратор Bosch GBH 2-26 DRE',
    url: 'https://shop.example/p/1204581',
    images: ['https://shop.example/img/1204581.jpg'],
    vendor: 'Bosch',
    price: 52990,
    currency: 'RUB',
    available: true,
    params: [
      { name: 'Мощность', value: '800', unit: 'Вт' },
      { name: 'Энергия удара', value: '2.7', unit: 'Дж' },
    ],
    stock: [{ outletId: 'derbent-1', qty: 3 }],
    updatedAt: new Date().toISOString(), // when it changed in your system
  },
  {
    id: 'docs-1204582',
    name: 'Бур SDS-plus 8×160 мм',
    url: 'https://shop.example/p/1204582',
    price: 390,
    currency: 'RUB',
    updatedAt: new Date().toISOString(),
  },
]);
const job = await lockstock.waitForJob(jobId);
console.log(`Products: ${job.status}`);

Price and stock

The fast path for frequent changes: only price, old price, availability and stock. A problem with one item — another currency, an unknown product — does not fail the batch; it is listed in the job's errors.

offers.mjs
import { LockstockPush } from './lockstock-push.mjs';

const lockstock = new LockstockPush({
  apiUrl: process.env.LOCKSTOCK_API_URL, // https://api.lockstock.example
  secretKey: process.env.LOCKSTOCK_SECRET_KEY, // sk_live_…: keep it on the server, never in a page
});

const [jobId] = await lockstock.updateOffers([
  {
    id: 'docs-1204581',
    price: 47990,
    oldPrice: 56990,
    available: true,
    stock: [{ outletId: 'derbent-1', qty: 2 }],
    updatedAt: new Date().toISOString(),
  },
]);
const job = await lockstock.waitForJob(jobId);
// A problem with one item does not fail the job: it is listed in `errors`.
for (const { id, reason } of job.errors) console.warn(`LockStock skipped ${id}: ${reason}`);
console.log(`Offers: ${job.status}`);

Remove a product

delete.mjs
import { LockstockPush } from './lockstock-push.mjs';

const lockstock = new LockstockPush({
  apiUrl: process.env.LOCKSTOCK_API_URL, // https://api.lockstock.example
  secretKey: process.env.LOCKSTOCK_SECRET_KEY, // sk_live_…: keep it on the server, never in a page
});

const job = await lockstock.waitForJob(await lockstock.deleteProduct('docs-1204582'));
console.log(`Delete: ${job.status}`);

Full reconciliation

Send your whole catalog when ours must match it exactly, removals included — for example, nightly. Products missing from the snapshot are removed from search, and every product in it is replaced as a whole, just like in upsertProducts. If that would remove more than 30% of your active products, the job ends rejected and nothing changes (the 30% safeguard). Run against a store with a real catalog, this example is rejected on purpose: its snapshot holds one product.

full-sync.mjs
import { LockstockPush } from './lockstock-push.mjs';

const lockstock = new LockstockPush({
  apiUrl: process.env.LOCKSTOCK_API_URL, // https://api.lockstock.example
  secretKey: process.env.LOCKSTOCK_SECRET_KEY, // sk_live_…: keep it on the server, never in a page
});

// Every product you sell, in any number: fullSync splits them into batches. Each product is
// replaced as a whole, so send all its fields, exactly as in products.mjs.
const allProducts = [
  {
    id: 'docs-1204581',
    name: 'Перфоратор Bosch GBH 2-26 DRE',
    url: 'https://shop.example/p/1204581',
    images: ['https://shop.example/img/1204581.jpg'],
    vendor: 'Bosch',
    price: 47990,
    oldPrice: 56990,
    currency: 'RUB',
    available: true,
    params: [
      { name: 'Мощность', value: '800', unit: 'Вт' },
      { name: 'Энергия удара', value: '2.7', unit: 'Дж' },
    ],
    stock: [{ outletId: 'derbent-1', qty: 2 }],
    updatedAt: new Date().toISOString(),
  },
];
const job = await lockstock.waitForJob(await lockstock.fullSync(allProducts));
if (job.status === 'rejected') {
  // The 30% safeguard: the snapshot would remove too many products. Nothing was changed.
  console.error(`Full sync rejected: ${job.error}`);
}
console.log(`Full sync: ${job.status}`);

На этой странице