How to buildOctober 11, 202612 min read
APIs & IntegrationsStep 8 of 10

1C for developers: HTTP services and OData

In short

1C:Enterprise exposes two HTTP entry points to external systems. The standard OData interface is an automatic REST API over OData 3.0 for every object included in it: no coding required, but broad and tied to the configuration's structure. HTTP services are a custom REST API that a 1C developer defines in the configuration: a narrow contract with business logic inside 1C. Reference data is convenient to read via OData; orders and rule-driven operations go through an HTTP service with an agreed contract.

How-toLevel: Intermediate1C IntegrationsPractice: ~1–2 h

After this you will be able to

  • choose between the standard OData interface and a custom 1C HTTP service for a given data flow
  • build an OData request URL for Catalog_Номенклатура with $filter, $select, $orderby, $top and $skip, encoding Cyrillic and guid'…' / datetime'…' literals correctly
  • write a Node.js client that parses value / odata.count and errors, reads a catalog page by page and detects changes via DataVersion
  • describe an HTTP service contract in OpenAPI and split the work with the 1C developer: source of truth and retries without duplicates

Verified with

1C:Enterprise 8.3 standard OData interface (OData v3, since 8.3.5) — not run on a real platformNode.js 24 LTS (24.18), built-in fetchTypeScript 7.0.2 (tsc --strict)Vitest 5.0.3, @redocly/cli 2.62.1 (OpenAPI 3.1)

Checked against docs: October 11, 2026

Contents
  1. 01What a web developer sees on the 1C side
  2. 02Publication and authentication
  3. 03OData URLs and options: $filter, $select, $expand, $top
  4. 04A Node client: reading, writing and posting
  5. 05Cyrillic object names
  6. 061C HTTP services: your own REST and a contract
  7. 07Conflicts, source of truth and alternatives
  8. 08Testing against a mock, limitations, exercises

What a web developer sees on the 1C side

The business side of the choice — which flow goes through which channel, files or queues, who owns the data — is covered in "How to integrate 1C with a web application". This guide is about technique: what requests the website sends, what the responses look like and what to ask the 1C developer for.

To an external system a 1C database is a publication on a web server (Apache or IIS) with an address like https://1c.example.kz/trade. It offers two HTTP interfaces. According to the platform site (v8.1c.ru), the standard OData interface is an automatically generated REST interface for the whole application over OData version 3.0, returning Atom/XML or JSON. HTTP services are configuration objects in which the developer builds the response in the 1C language: status code, headers and body.

Who builds it

Standard OData interface
enabled at publication; the 1C admin or developer sets which objects it includes
Custom HTTP service
a 1C developer writes URL templates and handlers

Configuration changes

Standard OData interface
none
Custom HTTP service
required (or a configuration extension)

What is exposed

Standard OData interface
catalogs, documents, registers, exchange plans and more — whatever is included
Custom HTTP service
exactly what the contract describes

Business logic

Standard OData interface
on the client side; 1C runs access checks and event handlers but not fill checks
Custom HTTP service
inside 1C: validation, counterparty matching, posting

Format

Standard OData interface
OData 3.0: resources like Catalog_Name, fields Ref_Key, *_Key
Custom HTTP service
anything, usually JSON per your contract

Coupling to structure

Standard OData interface
an attribute renamed in Designer breaks the client
Custom HTTP service
the contract is stable while 1C internals change freely

Publication and authentication

  1. Web server publication

    The 1C developer or admin publishes the database from Designer and enables OData; HTTP services are published the same way as web services. The result is the publication address that all URLs start from.

  2. Interface composition

    Objects available via OData are set with the global context method УстановитьСоставСтандартногоИнтерфейсаOData (SetStandardODataInterfaceContent). Configurations built on the Standard Subsystems Library have a data processor "Automatic REST service settings" for this. Include only what you need; 1C:Fresh specifically advises against exposing exchange plans.

  3. Service user

    A dedicated 1C user with only the required rights. In 1C:Fresh applications it gets the УдаленныйДоступOData (OData remote access) role. Never use a real employee's account.

  4. Authentication

    OData clients authenticate the same way as web service clients; the 1C:Fresh examples use an Authorization: Basic header with base64 login and password. Basic effectively sends the password in clear text, so the publication must be HTTPS-only.

  5. Smoke test

    A plain GET to …/odata/standard.odata/Catalog_Name/$count from a browser or curl: a number in the response means the publication, composition and rights are in place.

When reading and writing via OData the platform performs the usual access checks and calls object event handlers, but skips fill checks. If row-level access restrictions apply to some records, the request fails with 401 by default; the allowedOnly=true parameter asks for accessible records only. Keep this in mind: an "empty" response may mean restricted rights of the service user rather than missing data.

OData URLs and options: $filter, $select, $expand, $top

A request URL looks like publication-address/odata/standard.odata/resource?options. The resource is the configuration object type and name joined by an underscore; the name is exactly the "Name" property from Designer. Without $format=json (or an Accept: application/json header) the response comes back as XML.

Catalog_

1C object
catalog
Example
Catalog_Номенклатура(guid'…')

Document_

1C object
document
Example
Document_ЗаказПокупателя

InformationRegister_

1C object
information register
Example
InformationRegister_КурсыВалют/SliceLast(…)

AccumulationRegister_

1C object
accumulation register
Example
AccumulationRegister_Name/Balance(…) — balances

Constant_, ExchangePlan_, ChartOfAccounts_

1C object
constant, exchange plan, chart of accounts
Example
Constant_Name

suffix _TabularSectionName

1C object
tabular section rows
Example
Catalog_Организации_КонтактнаяИнформация

$select=Ref_Key,Description,Артикул

What it does
only the fields you need
1C specifics
always use it: objects carry dozens of attributes and tabular sections

$filter=Артикул eq 'КФ-001' and DeletionMark eq false

What it does
filter condition
1C specifics
operators eq ne gt ge lt le and or not, functions startswith, substringof, like, any/all over tabular sections

$orderby=Code desc

What it does
sorting
1C specifics
required for stable paging

$top=500&$skip=1000

What it does
a page
1C specifics
$skip is applied before $top

$expand=Физлицо

What it does
embed the related object instead of Физлицо_Key
1C specifics
lists only; does not expand tabular section attributes

$inlinecount=allpages

What it does
total record count
1C specifics
returned as a string in odata.count

/$count

What it does
count only
1C specifics
plain number, not JSON

Literals follow OData rules: strings in single quotes, references as guid'c3fbdcaf-…', dates as datetime'2026-10-01T00:00:00'. A single quote inside a string is doubled: the OData 4.01 spec states this explicitly, while version 3.0, which 1C implements, has no separate wording — test a filter containing an apostrophe on your database. 1C stores dates without a time zone, so pass them as wall-clock strings rather than via toISOString(), which shifts the time to UTC.

src/literals.ts
// Литералы для $filter и ключей стандартного интерфейса OData 1С (протокол OData v3)
const GUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
const DATETIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/;

export const EMPTY_REF = "00000000-0000-0000-0000-000000000000"; // пустая ссылка в полях *_Key

// строка в апострофах; апостроф внутри удваивается: 'Д''Артаньян'
export const str = (value: string) => `'${value.replaceAll("'", "''")}'`;

export function guid(value: string): string {
  if (!GUID.test(value)) throw new Error(`not a GUID: ${value}`);
  return `guid'${value.toLowerCase()}'`;
}

// 1С хранит дату без часового пояса: передаём «настенное» время строкой, не через Date и toISOString()
export function datetime(value: string): string {
  if (!DATETIME.test(value)) throw new Error(`expected YYYY-MM-DDThh:mm:ss, got ${value}`);
  return `datetime'${value}'`;
}

// Catalog_Номенклатура, Document_ЗаказПокупателя — имя как в свойстве «Имя» в конфигураторе
export type ObjectKind =
  | "Catalog"
  | "Document"
  | "InformationRegister"
  | "AccumulationRegister"
  | "Constant"
  | "ChartOfCharacteristicTypes";
export const entity = (kind: ObjectKind, name: string) => `${kind}_${name}`;
Literals and resource names: format checks before the request is sent
src/query.ts
import { guid } from "./literals.ts";

export type ListOptions = {
  select?: string[];
  filter?: string;
  expand?: string[];
  orderby?: string;
  top?: number;
  skip?: number;
  count?: boolean; // $inlinecount=allpages → поле odata.count в ответе
  allowedOnly?: boolean; // не падать с 401 на записях, закрытых ограничением доступа
};

// Кириллица → UTF-8 (%D0%9D…), пробел → %20, & # + % → кодируются; «, / :» оставляем читаемыми,
// как их отправляет браузер. «$» в именах параметров не кодируем
const enc = (value: string) =>
  encodeURIComponent(value).replace(/%2C/g, ",").replace(/%2F/g, "/").replace(/%3A/g, ":");
const q = (name: string, value: string) => `${name}=${enc(value)}`;

export function listUrl(root: string, resource: string, o: ListOptions = {}): string {
  const params = [q("$format", "json")];
  if (o.select?.length) params.push(q("$select", o.select.join(",")));
  if (o.filter) params.push(q("$filter", o.filter));
  if (o.expand?.length) params.push(q("$expand", o.expand.join(",")));
  if (o.orderby) params.push(q("$orderby", o.orderby));
  if (o.top !== undefined) params.push(q("$top", String(o.top)));
  if (o.skip) params.push(q("$skip", String(o.skip)));
  if (o.count) params.push(q("$inlinecount", "allpages"));
  if (o.allowedOnly) params.push(q("allowedOnly", "true"));
  return `${root}/${encodeURIComponent(resource)}?${params.join("&")}`;
}

// Catalog_Номенклатура(guid'…') — обращение к одному объекту по ссылке
export function entityUrl(root: string, resource: string, ref: string, select?: string[]): string {
  const params = [q("$format", "json"), ...(select?.length ? [q("$select", select.join(","))] : [])];
  return `${root}/${encodeURIComponent(resource)}(${encodeURIComponent(guid(ref))})?${params.join("&")}`;
}
URL building: Cyrillic and spaces are encoded, $format=json is always set

A list response is an object with odata.metadata and a value array. Standard attributes have English names (Ref_Key, DataVersion, DeletionMark, IsFolder, Parent_Key, Code, Description), configuration attributes keep their Designer names, and reference fields come with a _Key suffix holding a GUID; an empty reference is 00000000-0000-0000-0000-000000000000. The 1C:Fresh examples also use $format=json;odata=nometadata, a variant without the odata.metadata field.

A Node client: reading, writing and posting

The client keeps the …/odata/standard.odata root, the Basic header, timeouts and error parsing in one place. Create is a POST to the collection, update is a PATCH with only the changed fields (PUT requires all of them), and posting a document is POST …/Document_Name(guid'…')/Post(); for a successful posting 1C:Fresh shows a 200 with an empty body.

src/client.ts
import { guid } from "./literals.ts";
import { entityUrl, listUrl, type ListOptions } from "./query.ts";

export class OneCError extends Error {
  status: number;
  code: string | null;
  constructor(status: number, code: string | null, message: string) {
    super(`1C OData HTTP ${status}${code ? ` [${code}]` : ""}: ${message}`);
    this.status = status;
    this.code = code;
  }
}

type Options = {
  baseUrl: string; // https://1c.example.kz/trade — адрес публикации базы
  user: string; // служебный пользователь только с нужными правами
  password: string;
  timeoutMs?: number;
};

type ListResponse<T> = { "odata.metadata"?: string; "odata.count"?: string; value: T[] };

export class OneCODataClient {
  private root: string;
  private auth: string;
  private timeoutMs: number;

  constructor(o: Options) {
    this.root = `${o.baseUrl.replace(/\/$/, "")}/odata/standard.odata`;
    // Basic-аутентификация: только по HTTPS, иначе пароль уходит в открытом виде
    this.auth = `Basic ${Buffer.from(`${o.user}:${o.password}`, "utf8").toString("base64")}`;
    this.timeoutMs = o.timeoutMs ?? 30_000;
  }

  async list<T>(resource: string, o: ListOptions = {}): Promise<{ items: T[]; total: number | null }> {
    const body = await this.send<ListResponse<T>>("GET", listUrl(this.root, resource, o));
    return { items: body.value, total: body["odata.count"] ? Number(body["odata.count"]) : null };
  }

  // постранично через $top/$skip; без стабильного $orderby страницы могут пересекаться
  async *listAll<T>(resource: string, o: ListOptions & { orderby: string }, pageSize = 500): AsyncGenerator<T> {
    for (let skip = 0; ; skip += pageSize) {
      const { items } = await this.list<T>(resource, { ...o, top: pageSize, skip });
      yield* items;
      if (items.length < pageSize) return;
    }
  }

  get<T>(resource: string, ref: string, select?: string[]): Promise<T> {
    return this.send<T>("GET", entityUrl(this.root, resource, ref, select));
  }

  create<T>(resource: string, data: Record<string, unknown>): Promise<T> {
    return this.send<T>("POST", listUrl(this.root, resource), data);
  }

  // PATCH меняет только переданные реквизиты (PUT требует все)
  update<T>(resource: string, ref: string, data: Record<string, unknown>): Promise<T> {
    return this.send<T>("PATCH", entityUrl(this.root, resource, ref), data);
  }

  // проведение документа: POST …/Document_Имя(guid'…')/Post(); бизнес-логика 1С может отказать — HTTP 500
  async postDocument(resource: string, ref: string): Promise<void> {
    await this.send("POST", `${this.root}/${encodeURIComponent(resource)}(${guid(ref)})/Post()`);
  }

  private async send<T>(method: string, url: string, data?: unknown): Promise<T> {
    const res = await fetch(url, {
      method,
      headers: {
        Authorization: this.auth,
        Accept: "application/json",
        ...(data ? { "Content-Type": "application/json" } : {}),
      },
      body: data ? JSON.stringify(data) : undefined,
      signal: AbortSignal.timeout(this.timeoutMs),
    });
    const text = await res.text();
    if (!res.ok) throw toError(res.status, text);
    return (text ? JSON.parse(text) : undefined) as T;
  }
}

// Ошибка OData: { "odata.error" | "error": { code, message: { lang, value } } }; иначе — сырой текст
function toError(status: number, text: string): OneCError {
  try {
    const body = JSON.parse(text);
    const err = body["odata.error"] ?? body.error;
    if (err) return new OneCError(status, err.code ?? null, err.message?.value ?? String(err.message));
  } catch {
    // не JSON: страница веб-сервера, HTML с 401/403 и т. п.
  }
  return new OneCError(status, null, text.slice(0, 300) || "empty body");
}
1C OData client: list, paging, entity by reference, writes, posting, errors

The application layer maps 1C rows to the website model and decides what to sync. 1C owns the product catalog and the website only reads it: every N minutes we walk the catalog page by page and compare DataVersion with the stored value. In our experience this field changes on every write of the object, which makes it a cheap change marker; confirm that on your configuration before going live.

src/products.ts
import type { OneCODataClient } from "./client.ts";
import { EMPTY_REF, entity, str } from "./literals.ts";

// Строка справочника «Номенклатура» так, как её отдаёт OData: служебные реквизиты — на английском,
// реквизиты конфигурации — как в конфигураторе, ссылки — с суффиксом _Key
export type NomenclatureRow = {
  Ref_Key: string;
  DataVersion: string;
  DeletionMark: boolean;
  IsFolder: boolean;
  Parent_Key: string;
  Code: string;
  Description: string;
  Артикул: string;
  ЕдиницаИзмерения_Key: string;
};

export type Product = {
  externalRef: string; // GUID из 1С — единственный устойчивый ключ
  sku: string;
  title: string;
  groupRef: string | null;
  version: string;
  archived: boolean;
};

export const NOMENCLATURE = entity("Catalog", "Номенклатура");
const FIELDS = ["Ref_Key", "DataVersion", "DeletionMark", "Parent_Key", "Code", "Description", "Артикул"];

export function toProduct(row: Pick<NomenclatureRow, "Ref_Key" | "DataVersion" | "DeletionMark" | "Parent_Key" | "Description" | "Артикул">): Product {
  return {
    externalRef: row.Ref_Key,
    sku: row.Артикул.trim(),
    title: row.Description.trim(),
    groupRef: row.Parent_Key === EMPTY_REF ? null : row.Parent_Key,
    version: row.DataVersion,
    archived: row.DeletionMark,
  };
}

// Полная сверка справочника: 1С — источник правды по товарам, сайт только читает.
// DataVersion сравниваем с сохранённой — переписываем на сайте только изменившиеся позиции
export async function syncProducts(
  client: OneCODataClient,
  known: Map<string, string>, // Ref_Key → DataVersion из прошлой синхронизации
  pageSize = 500,
): Promise<{ changed: Product[]; missing: string[] }> {
  const changed: Product[] = [];
  const seen = new Set<string>();
  const rows = client.listAll<NomenclatureRow>(
    NOMENCLATURE,
    { select: FIELDS, filter: "IsFolder eq false", orderby: "Code" },
    pageSize,
  );
  for await (const row of rows) {
    seen.add(row.Ref_Key);
    if (known.get(row.Ref_Key) !== row.DataVersion) changed.push(toProduct(row));
  }
  const missing = [...known.keys()].filter((ref) => !seen.has(ref)); // удалены в 1С напрямую
  return { changed, missing };
}

export async function findBySku(client: OneCODataClient, sku: string): Promise<Product | null> {
  const { items } = await client.list<NomenclatureRow>(NOMENCLATURE, {
    select: FIELDS,
    filter: `Артикул eq ${str(sku)} and DeletionMark eq false`,
    top: 2,
  });
  if (items.length > 1) throw new Error(`SKU ${sku} is not unique in 1C`); // артикул в 1С не обязан быть уникальным
  return items[0] ? toProduct(items[0]) : null;
}
Products: types with Cyrillic fields, lookup by SKU and reconciliation via DataVersion

Errors: 4xx means a client error, 5xx a server error; per the platform description, for 4xx 1C may pass an internal error code and message. The client takes them from an odata.error or error object (code and message.value per the OData spec), and if the body is not JSON — say, a web server page with a 401 — keeps the start of the text. A business-logic refusal to post a document comes back as a 500 in the 1C:Fresh example — do not retry such a request automatically.

Cyrillic object names

Resource and attribute names in OData match the names in Designer, which means they are almost always Cyrillic: Catalog_Номенклатура, Артикул, ЕдиницаИзмерения_Key. That is easy to live with if you follow four rules.

  • In URLs Cyrillic is UTF-8 percent-encoded — encodeURIComponent does it for you; never build URLs by plain string concatenation
  • JSON keys are Cyrillic too: in TypeScript they are ordinary properties (row.Артикул), but declare them in a type so the compiler catches typos
  • A 1C name is part of the contract: renaming an attribute in Designer silently breaks the OData client. Write down the fields you use and agree that they change only with notice
  • The website model exposes its own Latin names (sku, title) — Cyrillic stays inside the 1C adapter

1C HTTP services: your own REST and a contract

An HTTP service is a configuration object under "Common — HTTP services". In it the 1C developer defines URL templates (with parameters like {id}) and, for each template, methods: the HTTP method handled and a procedure in the 1C language. The platform matches the incoming URL against the templates; with no match it returns 404, otherwise it passes the request to the handler as an HTTPСервисЗапрос (HTTP service request) object and expects an HTTPСервисОтвет (response) with status, headers and body. Publication and authentication work the same as for web services.

In our experience, a typical publication exposes the service at publication-address/hs/root-URL/template, for example https://1c.example.kz/trade/hs/site/v1/orders. Get the exact address from the 1C developer after publication: it depends on the web server settings.

The main value of an HTTP service is a contract that does not depend on configuration internals. Describe it in OpenAPI before development: the web developer writes the client and a mock, the 1C developer writes the handlers, and the contract file becomes the shared source of truth about formats. Below is a template: stock per warehouse with a change marker, and order intake with an idempotency key.

contract/site-api.yaml
openapi: 3.1.0
info:
  title: 1C HTTP service for the website
  version: 1.0.0
  description: >
    Контракт HTTP-сервиса 1С «site» (корневой URL site). Реализует программист 1С,
    вызывает сайт. Все ссылки — GUID 1С, даты — без часового пояса (время 1С).
servers:
  - url: https://1c.example.kz/trade/hs/site
security:
  - basicAuth: []
paths:
  /v1/stock:
    get:
      operationId: getStock
      summary: Остатки по складу, изменённые после метки
      parameters:
        - name: warehouse
          in: query
          required: true
          schema: { type: string, format: uuid }
        - name: changedSince
          in: query
          required: false
          schema: { type: string, pattern: '^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$' }
      responses:
        '200':
          description: Остатки
          content:
            application/json:
              schema:
                type: object
                required: [items, asOf]
                properties:
                  asOf: { type: string, description: Метка для следующего запроса }
                  items:
                    type: array
                    items:
                      type: object
                      required: [productRef, quantity]
                      properties:
                        productRef: { type: string, format: uuid }
                        quantity: { type: number }
        '400': { $ref: '#/components/responses/Problem' }
        '401': { description: Нет или неверные учётные данные }
  /v1/orders:
    post:
      operationId: createOrder
      summary: Принять заказ с сайта (идемпотентно по Idempotency-Key)
      parameters:
        - name: Idempotency-Key
          in: header
          required: true
          description: id заказа на сайте; повтор с тем же ключом возвращает тот же документ
          schema: { type: string, maxLength: 64 }
      requestBody:
        required: true
        content:
          application/json:
            schema: { $ref: '#/components/schemas/Order' }
      responses:
        '201':
          description: Документ создан
          content:
            application/json:
              schema: { $ref: '#/components/schemas/OrderAccepted' }
        '200':
          description: Повтор — документ уже был создан с этим ключом
          content:
            application/json:
              schema: { $ref: '#/components/schemas/OrderAccepted' }
        '409': { $ref: '#/components/responses/Problem' }
        '422': { $ref: '#/components/responses/Problem' }
components:
  securitySchemes:
    basicAuth: { type: http, scheme: basic }
  schemas:
    Order:
      type: object
      required: [siteOrderId, customer, lines]
      properties:
        siteOrderId: { type: string }
        customer:
          type: object
          required: [name, phone]
          properties:
            name: { type: string }
            phone: { type: string, description: 'E.164, например +77011234567' }
            counterpartyRef: { type: string, format: uuid, description: если клиент уже сопоставлен }
        lines:
          type: array
          minItems: 1
          items:
            type: object
            required: [productRef, quantity, price]
            properties:
              productRef: { type: string, format: uuid }
              quantity: { type: number, exclusiveMinimum: 0 }
              price: { type: number, minimum: 0 }
    OrderAccepted:
      type: object
      required: [documentRef, number]
      properties:
        documentRef: { type: string, format: uuid }
        number: { type: string }
    Problem:
      type: object
      required: [code, message]
      properties:
        code: { type: string, description: 'PRODUCT_NOT_FOUND, PRICE_CHANGED, ...' }
        message: { type: string }
  responses:
    Problem:
      description: Ошибка бизнес-проверки
      content:
        application/json:
          schema: { $ref: '#/components/schemas/Problem' }
Contract template for the "site" HTTP service in OpenAPI 3.1 (redocly lint — no errors)
src/hs-client.ts
// Клиент собственного HTTP-сервиса 1С по контракту contract/site-api.yaml
export type OrderLine = { productRef: string; quantity: number; price: number };
export type Order = {
  siteOrderId: string;
  customer: { name: string; phone: string; counterpartyRef?: string };
  lines: OrderLine[];
};
export type OrderAccepted = { documentRef: string; number: string; repeated: boolean };

export class OneCServiceError extends Error {
  status: number;
  code: string;
  constructor(status: number, code: string, message: string) {
    super(`1C service HTTP ${status} [${code}]: ${message}`);
    this.status = status;
    this.code = code;
  }
}

export class SiteServiceClient {
  private base: string;
  private auth: string;

  constructor(baseUrl: string, user: string, password: string) {
    this.base = baseUrl.replace(/\/$/, ""); // …/hs/site — корневой URL сервиса
    this.auth = `Basic ${Buffer.from(`${user}:${password}`, "utf8").toString("base64")}`;
  }

  async createOrder(order: Order): Promise<OrderAccepted> {
    const res = await fetch(`${this.base}/v1/orders`, {
      method: "POST",
      headers: {
        Authorization: this.auth,
        "Content-Type": "application/json; charset=utf-8",
        "Idempotency-Key": order.siteOrderId, // повтор после таймаута не создаст второй документ
      },
      body: JSON.stringify(order),
      signal: AbortSignal.timeout(30_000),
    });
    const body = await res.json().catch(() => ({}));
    if (res.status === 201 || res.status === 200) return { ...body, repeated: res.status === 200 };
    throw new OneCServiceError(res.status, body.code ?? "UNKNOWN", body.message ?? res.statusText);
  }

  async getStock(warehouseRef: string, changedSince?: string) {
    const params = new URLSearchParams({ warehouse: warehouseRef });
    if (changedSince) params.set("changedSince", changedSince);
    const res = await fetch(`${this.base}/v1/stock?${params}`, {
      headers: { Authorization: this.auth },
      signal: AbortSignal.timeout(30_000),
    });
    const body = await res.json().catch(() => ({}));
    if (!res.ok) throw new OneCServiceError(res.status, body.code ?? "UNKNOWN", body.message ?? res.statusText);
    return body as { asOf: string; items: { productRef: string; quantity: number }[] };
  }
}
HTTP service client: Idempotency-Key = the website order id, a retry returns the same document

Contract

Web developer
proposes fields and status codes from the website side
1C developer
checks them against 1C accounting: which documents, catalogs, posting

Idempotency

Web developer
sends Idempotency-Key, retries on timeout
1C developer
stores the key (e.g. in a document attribute) and returns the already created document

Errors

Web developer
tells 4xx (do not retry) from 5xx/timeouts (retry)
1C developer
returns clear code values: PRODUCT_NOT_FOUND, PRICE_CHANGED

Tests

Web developer
a mock per contract and client tests
1C developer
handler checks on a copy of the database

Conflicts, source of truth and alternatives

A conflict arises when both sides change the same field. The cure is to assign an owner to every entity and field up front and write only to your own.

Products, prices, stock

Source of truth
1C
Flow
1C → site: OData or HTTP service on a schedule, the site only reads

Website order

Source of truth
the site until hand-over, then 1C
Flow
site → 1C once via the HTTP service; afterwards status is read from 1C

Counterparty

Source of truth
1C
Flow
the site sends phone and name; matching and creation happen in 1C

Online payment

Source of truth
payment provider → site
Flow
site → 1C in a separate call with an idempotency key
  • The site stores the 1C GUID (Ref_Key), not the code or name: codes change on renumbering, names are edited by hand
  • Writes via OData bypass fill checks, so validate required fields on your side before sending
  • OData has a 1C_OData-DataLoadMode: true header that emulates writing in data exchange mode (ОбменДанными.Загрузка = Истина); 1C:Fresh does not support it. Use it only in agreement with the 1C developer: in this mode some checks and handlers may not run
  • Performance: always $select, pages of a few hundred rows with $orderby, $expand only on small result sets, stock via register virtual tables (Balance) rather than reading all movements

The alternative to HTTP is file or queue exchange. A scheduled XML (including CommerceML) or CSV export over SFTP fits when 1C cannot be published externally or data is needed once a day; a message queue fits when there are three or more systems. These options and the selection criteria are covered in "How to integrate 1C with a web application"; idempotent delivery and retries are covered in the material on events and queues.

Testing against a mock, limitations, exercises

No 1C instance was available for this check, so the client was tested against a local node:http mock. The mock answers in the format of the 1C:Fresh examples — odata.metadata, value, odata.count as a string, Ref_Key and DataVersion — parses a subset of $filter (comparisons, and/or/not, parentheses, startswith, substringof), supports $select, $orderby, $top/$skip, create, PATCH, Post() and the "site" HTTP service per the contract. It does not emulate rights, $metadata, tabular sections or registers.

bash
npm init -y && npm pkg set type=module
npm install -D typescript@7.0.2 @types/node@24 vitest@5.0.3 @redocly/cli@2.62.1
npx tsc -p tsconfig.json                 # types, strict
npx vitest run                           # 13 tests: URLs, literals, response parsing, HTTP service
npx redocly lint contract/site-api.yaml  # the HTTP service contract is valid
node mock/onec-mock.ts                   # mock on its own, for curl or browser requests
Example project: dependencies and check commands
tsconfig.json
{
  "compilerOptions": {
    "target": "es2023",
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "strict": true,
    "noEmit": true,
    "allowImportingTsExtensions": true,
    "erasableSyntaxOnly": true,
    "verbatimModuleSyntax": true,
    "types": ["node"],
    "skipLibCheck": true
  },
  "include": ["src", "mock", "test"]
}
tsconfig: Node 24 runs .ts without a build step — .ts import extensions, erasable syntax only
mock/onec-mock.ts
// Мок публикации базы 1С: подмножество стандартного интерфейса OData (формат ответов — по примерам
// из документации 1С:Фреш) и HTTP-сервис «site» по нашему контракту. Не эмулирует права, $metadata,
// табличные части, регистры и $expand — только то, что нужно тестам клиента.
import { randomUUID } from "node:crypto";
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import type { AddressInfo } from "node:net";

type Row = Record<string, unknown>;
type Lit = string | number | boolean;

const json = (res: ServerResponse, status: number, body?: unknown) => {
  res.writeHead(status, body === undefined ? {} : { "Content-Type": "application/json;charset=utf-8" });
  res.end(body === undefined ? undefined : JSON.stringify(body));
};
const odataError = (res: ServerResponse, status: number, code: string, value: string) =>
  json(res, status, { "odata.error": { code, message: { lang: "ru", value } } });
const readBody = async (req: IncomingMessage) => {
  let s = "";
  for await (const c of req) s += c;
  return s ? JSON.parse(s) : null;
};

/* ---------- мини-разбор $filter: сравнения, and/or/not, скобки, startswith/substringof ---------- */
const TOKEN = /\s*(guid'[^']*'|datetime'[^']*'|'(?:[^']|'')*'|-?\d+(?:\.\d+)?|\(|\)|,|[\p{L}_][\p{L}\p{N}_/]*)/uy;
function tokenize(src: string): string[] {
  const out: string[] = [];
  TOKEN.lastIndex = 0;
  while (TOKEN.lastIndex < src.length) {
    const m = TOKEN.exec(src);
    if (!m) throw new Error(`bad token at ${TOKEN.lastIndex}`);
    out.push(m[1]);
    if (/^\s*$/.test(src.slice(TOKEN.lastIndex))) break;
  }
  return out;
}
function literal(t: string): Lit {
  if (t.startsWith("guid'") || t.startsWith("datetime'")) return t.slice(t.indexOf("'") + 1, -1);
  if (t.startsWith("'")) return t.slice(1, -1).replaceAll("''", "'");
  if (t === "true" || t === "false") return t === "true";
  if (/^-?\d/.test(t)) return Number(t);
  throw new Error(`expected literal, got ${t}`);
}
function compileFilter(src: string): (row: Row) => boolean {
  const t = tokenize(src);
  let i = 0;
  const expect = (v: string) => {
    if (t[i++] !== v) throw new Error(`expected ${v}`);
  };
  const or = (): ((r: Row) => boolean) => {
    let left = and();
    while (t[i] === "or") {
      i++;
      const l = left, r = and();
      left = (row) => l(row) || r(row);
    }
    return left;
  };
  const and = (): ((r: Row) => boolean) => {
    let left = unary();
    while (t[i] === "and") {
      i++;
      const l = left, r = unary();
      left = (row) => l(row) && r(row);
    }
    return left;
  };
  const unary = (): ((r: Row) => boolean) => {
    if (t[i] === "not") {
      i++;
      const inner = unary();
      return (row) => !inner(row);
    }
    if (t[i] === "(") {
      i++;
      const inner = or();
      expect(")");
      return inner;
    }
    if (t[i] === "startswith" || t[i] === "substringof") {
      const fn = t[i++];
      expect("(");
      const a = t[i++];
      expect(",");
      const b = t[i++];
      expect(")");
      return fn === "startswith"
        ? (row) => String(row[a]).startsWith(String(literal(b)))
        : (row) => String(row[b]).includes(String(literal(a)));
    }
    const field = t[i++], op = t[i++], value = literal(t[i++]);
    const cmp: Record<string, (x: Lit, y: Lit) => boolean> = {
      eq: (x, y) => x === y, ne: (x, y) => x !== y, gt: (x, y) => x > y,
      ge: (x, y) => x >= y, lt: (x, y) => x < y, le: (x, y) => x <= y,
    };
    if (!cmp[op]) throw new Error(`unknown operator ${op}`);
    return (row) => cmp[op](row[field] as Lit, value);
  };
  const fn = or();
  if (i !== t.length) throw new Error(`unexpected ${t[i]}`);
  return fn;
}

/* ---------------------------------------- сервер ---------------------------------------- */
export async function startOneCMock(opts: { user?: string; password?: string; base?: string } = {}) {
  const base = opts.base ?? "/trade";
  const auth = `Basic ${Buffer.from(`${opts.user ?? "site-sync"}:${opts.password ?? "test-password"}`).toString("base64")}`;
  const data: Record<string, Row[]> = { "Catalog_Номенклатура": [], "Document_ЗаказПокупателя": [] };
  const orders = new Map<string, { documentRef: string; number: string }>();
  const requests: string[] = [];
  const version = () => Buffer.from(randomUUID().slice(0, 8)).toString("base64");

  const server = createServer(async (req, res) => {
    const url = new URL(req.url ?? "/", "http://mock");
    const path = decodeURIComponent(url.pathname);
    requests.push(`${req.method} ${path}${decodeURIComponent(url.search)}`);
    if (req.headers.authorization !== auth) {
      res.writeHead(401, { "WWW-Authenticate": 'Basic realm="1C"' }).end();
      return;
    }

    /* --- HTTP-сервис site по контракту --- */
    if (path === `${base}/hs/site/v1/orders` && req.method === "POST") {
      const key = String(req.headers["idempotency-key"] ?? "");
      if (!key) return json(res, 400, { code: "NO_IDEMPOTENCY_KEY", message: "Idempotency-Key is required" });
      const known = orders.get(key);
      if (known) return json(res, 200, known);
      const order = await readBody(req);
      const missing = (order?.lines ?? []).find(
        (l: { productRef: string }) => !data["Catalog_Номенклатура"].some((r) => r.Ref_Key === l.productRef),
      );
      if (!order?.lines?.length || missing)
        return json(res, 422, { code: "PRODUCT_NOT_FOUND", message: `unknown product ${missing?.productRef ?? "-"}` });
      const accepted = { documentRef: randomUUID(), number: `САЙТ-${String(orders.size + 1).padStart(6, "0")}` };
      orders.set(key, accepted);
      return json(res, 201, accepted);
    }
    if (path === `${base}/hs/site/v1/stock` && req.method === "GET") {
      if (!url.searchParams.get("warehouse")) return json(res, 400, { code: "NO_WAREHOUSE", message: "warehouse is required" });
      const items = data["Catalog_Номенклатура"].map((r) => ({ productRef: r.Ref_Key, quantity: 5 }));
      return json(res, 200, { asOf: "2026-10-11T12:00:00", items });
    }

    /* --- стандартный интерфейс OData --- */
    const prefix = `${base}/odata/standard.odata/`;
    if (!path.startsWith(prefix)) return json(res, 404);
    const m = /^([\p{L}_]+)(?:\(guid'([0-9a-f-]{36})'\))?(\/Post\(\))?$/u.exec(path.slice(prefix.length));
    if (!m || !data[m[1]]) return odataError(res, 404, "-1", `Сущность не найдена: ${path.slice(prefix.length)}`);
    const [, set, ref, action] = m;
    const rows = data[set];
    const metadata = `http://mock${prefix}$metadata#${set}`;

    if (ref) {
      const row = rows.find((r) => r.Ref_Key === ref);
      if (!row) return json(res, 404);
      if (action && req.method === "POST") {
        if (!row.Контрагент_Key) return odataError(res, 500, "-1", "Не заполнен контрагент: документ не может быть проведён");
        row.Posted = true;
        return json(res, 200);
      }
      if (req.method === "PATCH") {
        Object.assign(row, await readBody(req), { DataVersion: version() });
        return json(res, 200, { "odata.metadata": `${metadata}/@Element`, ...row });
      }
      return json(res, 200, { "odata.metadata": `${metadata}/@Element`, ...row });
    }

    if (req.method === "POST") {
      const row = { ...(await readBody(req)), Ref_Key: randomUUID(), DataVersion: version() };
      rows.push(row);
      return json(res, 201, { "odata.metadata": `${metadata}/@Element`, ...row });
    }

    let list = rows;
    const p = url.searchParams;
    try {
      const filter = p.get("$filter");
      if (filter) list = list.filter(compileFilter(filter));
    } catch (e) {
      return odataError(res, 400, "-1", `Неправильный формат параметра $filter: ${(e as Error).message}`);
    }
    const orderby = p.get("$orderby");
    if (orderby) {
      const [field, dir] = orderby.trim().split(/\s+/);
      list = [...list].sort((a, b) => String(a[field]).localeCompare(String(b[field])) * (dir === "desc" ? -1 : 1));
    }
    const total = list.length;
    const skip = Number(p.get("$skip") ?? 0);
    const top = p.get("$top") === null ? list.length : Number(p.get("$top"));
    const select = p.get("$select")?.split(",");
    const value = list
      .slice(skip, skip + top)
      .map((r) => (select ? Object.fromEntries(select.map((k) => [k, r[k]])) : r));
    return json(res, 200, {
      "odata.metadata": metadata,
      ...(p.get("$inlinecount") === "allpages" ? { "odata.count": String(total) } : {}),
      value,
    });
  });

  await new Promise<void>((r) => server.listen(0, "127.0.0.1", r));
  const { port } = server.address() as AddressInfo;
  return {
    url: `http://127.0.0.1:${port}${base}`,
    data,
    requests,
    addProduct(code: string, description: string, sku: string, extra: Row = {}): Row {
      const row: Row = {
        Ref_Key: randomUUID(),
        DataVersion: version(),
        DeletionMark: false,
        IsFolder: false,
        Parent_Key: "00000000-0000-0000-0000-000000000000",
        Code: code,
        Description: description,
        Артикул: sku,
        ЕдиницаИзмерения_Key: "00000000-0000-0000-0000-000000000000",
        ...extra,
      };
      data["Catalog_Номенклатура"].push(row);
      return row;
    },
    touch(row: Row, patch: Row) {
      Object.assign(row, patch, { DataVersion: version() });
    },
    close: () => new Promise<void>((r) => server.close(() => r())),
  };
}

if (import.meta.main) {
  const mock = await startOneCMock();
  mock.addProduct("00-00000001", "Кофе в зёрнах 1 кг", "КФ-001");
  console.log(`1C mock: ${mock.url}/odata/standard.odata  (Basic site-sync / test-password)`);
}
1C publication mock for tests — not a substitute for a real database
test/query.test.ts
import { describe, expect, it } from "vitest";
import { datetime, entity, guid, str } from "../src/literals.ts";
import { entityUrl, listUrl } from "../src/query.ts";

const root = "https://1c.example.kz/trade/odata/standard.odata";

describe("литералы", () => {
  it("строка: апостроф удваивается", () => {
    expect(str("Д'Артаньян")).toBe("'Д''Артаньян'");
  });
  it("guid и datetime проверяются по формату", () => {
    expect(guid("C3FBDCAF-B8E1-11E4-8271-001E101F0864")).toBe("guid'c3fbdcaf-b8e1-11e4-8271-001e101f0864'");
    expect(() => guid("42")).toThrow(/not a GUID/);
    expect(datetime("2026-10-01T00:00:00")).toBe("datetime'2026-10-01T00:00:00'");
    expect(() => datetime("2026-10-01T00:00:00Z")).toThrow();
  });
  it("имя ресурса из вида и имени объекта", () => {
    expect(entity("Catalog", "Номенклатура")).toBe("Catalog_Номенклатура");
  });
});

describe("сборка URL", () => {
  it("список: $format, $select, $filter, $orderby, $top/$skip, кириллица в UTF-8", () => {
    const url = listUrl(root, "Catalog_Номенклатура", {
      select: ["Ref_Key", "Description", "Артикул"],
      filter: `Артикул eq ${str("КФ-001")} and DeletionMark eq false`,
      orderby: "Code",
      top: 50,
      skip: 100,
      count: true,
    });
    expect(url).toBe(
      root +
        "/Catalog_%D0%9D%D0%BE%D0%BC%D0%B5%D0%BD%D0%BA%D0%BB%D0%B0%D1%82%D1%83%D1%80%D0%B0" +
        "?$format=json" +
        "&$select=Ref_Key,Description,%D0%90%D1%80%D1%82%D0%B8%D0%BA%D1%83%D0%BB" +
        "&$filter=%D0%90%D1%80%D1%82%D0%B8%D0%BA%D1%83%D0%BB%20eq%20'%D0%9A%D0%A4-001'%20and%20DeletionMark%20eq%20false" +
        "&$orderby=Code&$top=50&$skip=100&$inlinecount=allpages",
    );
    // после декодирования — ровно то, что пишут в документации 1С
    expect(decodeURIComponent(url)).toContain("Catalog_Номенклатура?$format=json&$select=Ref_Key,Description,Артикул");
  });

  it("символы & и # в значении фильтра не ломают строку запроса", () => {
    const url = listUrl(root, "Catalog_Номенклатура", { filter: `Description eq ${str("Чай & кофе #1")}` });
    expect(url).toContain("%26");
    expect(url).toContain("%23");
    expect(new URL(url).searchParams.get("$filter")).toBe("Description eq 'Чай & кофе #1'");
  });

  it("объект по ссылке", () => {
    const url = entityUrl(root, "Document_ЗаказПокупателя", "f3f4dc08-4553-11ed-8d7b-005056892602", ["Number", "Posted"]);
    expect(decodeURIComponent(url)).toBe(
      `${root}/Document_ЗаказПокупателя(guid'f3f4dc08-4553-11ed-8d7b-005056892602')?$format=json&$select=Number,Posted`,
    );
  });
});
URL building and literal tests
test/client.test.ts
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { startOneCMock } from "../mock/onec-mock.ts";
import { OneCError, OneCODataClient } from "../src/client.ts";
import { NOMENCLATURE, findBySku, syncProducts, toProduct } from "../src/products.ts";
import { OneCServiceError, SiteServiceClient } from "../src/hs-client.ts";

let mock: Awaited<ReturnType<typeof startOneCMock>>;
let client: OneCODataClient;

beforeEach(async () => {
  mock = await startOneCMock();
  client = new OneCODataClient({ baseUrl: mock.url, user: "site-sync", password: "test-password" });
});
afterEach(() => mock.close());

describe("OData-клиент против мока 1С", () => {
  it("разбирает ответ: value, odata.count, кириллические реквизиты", async () => {
    mock.addProduct("00-001", "Кофе в зёрнах 1 кг", "КФ-001");
    mock.addProduct("00-002", "Чай зелёный", "ЧЙ-002");
    const { items, total } = await client.list<Record<string, unknown>>(NOMENCLATURE, {
      select: ["Description", "Артикул"],
      filter: "startswith(Description, 'Кофе')",
      count: true,
    });
    expect(items).toEqual([{ Description: "Кофе в зёрнах 1 кг", Артикул: "КФ-001" }]);
    expect(total).toBe(1);
  });

  it("поиск по артикулу с апострофом и защита от неуникального артикула", async () => {
    const row = mock.addProduct("00-003", "Сыр д'Аффинуа", "Д'А-1");
    expect(await findBySku(client, "Д'А-1")).toMatchObject({ externalRef: row.Ref_Key, sku: "Д'А-1", groupRef: null });
    expect(await findBySku(client, "нет-такого")).toBeNull();
    mock.addProduct("00-004", "Дубль", "Д'А-1");
    await expect(findBySku(client, "Д'А-1")).rejects.toThrow(/not unique/);
  });

  it("сверка по DataVersion: постранично, только изменённые и пропавшие", async () => {
    const rows = ["00-001", "00-002", "00-003", "00-004", "00-005"].map((code, i) => mock.addProduct(code, `Товар ${i}`, `SKU-${i}`));
    const first = await syncProducts(client, new Map(), 2);
    expect(first.changed).toHaveLength(5);
    expect(mock.requests.filter((r) => r.includes("$skip") || r.includes("$top=2"))).toHaveLength(3);

    const known = new Map(first.changed.map((p) => [p.externalRef, p.version]));
    mock.touch(rows[1], { Description: "Товар 1 (новая упаковка)" });
    mock.data[NOMENCLATURE].splice(4, 1); // объект удалён в 1С напрямую
    const second = await syncProducts(client, known, 2);
    expect(second.changed.map((p) => p.title)).toEqual(["Товар 1 (новая упаковка)"]);
    expect(second.missing).toEqual([rows[4].Ref_Key]);
  });

  it("создание, PATCH и проведение документа; отказ проведения — 500 с текстом", async () => {
    const doc = await client.create<{ Ref_Key: string }>("Document_ЗаказПокупателя", { Date: "2026-10-11T12:00:00" });
    await expect(client.postDocument("Document_ЗаказПокупателя", doc.Ref_Key)).rejects.toMatchObject({ status: 500 });
    await client.update("Document_ЗаказПокупателя", doc.Ref_Key, { Контрагент_Key: "c3fbdcaf-b8e1-11e4-8271-001e101f0864" });
    await client.postDocument("Document_ЗаказПокупателя", doc.Ref_Key);
    expect(await client.get<{ Posted: boolean }>("Document_ЗаказПокупателя", doc.Ref_Key, ["Posted"])).toMatchObject({ Posted: true });
  });

  it("ошибки: 400 с odata.error, 401 без JSON", async () => {
    const err = await client.list(NOMENCLATURE, { filter: "Артикул equals 'x'" }).catch((e) => e);
    expect(err).toBeInstanceOf(OneCError);
    expect(err).toMatchObject({ status: 400, code: "-1" });
    expect(err.message).toContain("$filter");

    const anonymous = new OneCODataClient({ baseUrl: mock.url, user: "site-sync", password: "wrong" });
    await expect(anonymous.list(NOMENCLATURE)).rejects.toMatchObject({ status: 401, code: null });
  });

  it("toProduct: пустая ссылка группы → null, пробелы обрезаются", () => {
    expect(
      toProduct({
        Ref_Key: "baebad03-b80e-11e4-826f-001e101fe696",
        DataVersion: "AAAAAAAAAAA=",
        DeletionMark: true,
        Parent_Key: "00000000-0000-0000-0000-000000000000",
        Description: " Таз 25 л ",
        Артикул: "8940 ",
      }),
    ).toEqual({ externalRef: "baebad03-b80e-11e4-826f-001e101fe696", sku: "8940", title: "Таз 25 л", groupRef: null, version: "AAAAAAAAAAA=", archived: true });
  });
});

describe("клиент HTTP-сервиса по контракту", () => {
  it("заказ идемпотентен по Idempotency-Key; неизвестный товар — 422", async () => {
    const hs = new SiteServiceClient(`${mock.url}/hs/site`, "site-sync", "test-password");
    const p = mock.addProduct("00-001", "Кофе", "КФ-001");
    const order = { siteOrderId: "web-1042", customer: { name: "Айгерим", phone: "+77011234567" }, lines: [{ productRef: String(p.Ref_Key), quantity: 2, price: 9900 }] };

    const a = await hs.createOrder(order);
    const b = await hs.createOrder(order);
    expect(a).toMatchObject({ number: "САЙТ-000001", repeated: false });
    expect(b).toEqual({ ...a, repeated: true });

    const bad = { ...order, siteOrderId: "web-1043", lines: [{ productRef: "c3fbdcaf-b8e1-11e4-8271-001e101f0864", quantity: 1, price: 1 }] };
    await expect(hs.createOrder(bad)).rejects.toMatchObject({ status: 422, code: "PRODUCT_NOT_FOUND" });
    await expect(hs.createOrder(bad)).rejects.toBeInstanceOf(OneCServiceError);

    const stock = await hs.getStock("de0977f8-b6af-11e4-bf8c-74d02b7dfd8c", "2026-10-11T00:00:00");
    expect(stock.items).toEqual([{ productRef: p.Ref_Key, quantity: 5 }]);
  });
});
Client and HTTP service tests against the mock

Result: tsc passes, all 13 Vitest tests are green, and redocly lint reports the contract as valid (one warning — no license field). The code has not been run against a real 1C: verify URL encoding, $filter parsing, the error body format and DataVersion behaviour on a copy of your database before going live.

  • A request without $format=json — the response is XML and JSON.parse fails
  • A service user with full rights — a leaked password opens the whole database
  • An HTTP publication with Basic auth — the password travels in clear text
  • Syncing by code or name instead of Ref_Key — duplicates after renumbering
  • Paging without $orderby — records are skipped or repeated
  • Dates via new Date().toISOString() — shifted by the time zone
How this was verified3 · passed1 · not runchecked 11 October 2026

“Run” means the code was executed in a scratch project or container and the output in the article comes from that run. “Partial” means it was checked against stubs instead of external services. “Not run” means it needs a device, an account or a key; the steps were checked against the official documentation. The verification project files and run logs are kept in the InterLAB archive.

Tell us about your task — we'll review your request and get back to you

Got a project?
We'll scope and estimate it — for free.

Tell us about the product or the problem. After reviewing the task we'll come back with an architecture, a timeline and a price for the first version.

  • Scope and risks estimated before we start
  • NDA — yours or ours
  • Work starts within a week

Please do not send passwords, access keys or source code through this form — we will agree a safe way to share access separately.

By clicking the button you agree to the processing of your personal data.