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.
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,$topand$skip, encoding Cyrillic andguid'…'/datetime'…'literals correctly - write a Node.js client that parses
value/odata.countand errors, reads a catalog page by page and detects changes viaDataVersion - describe an HTTP service contract in OpenAPI and split the work with the 1C developer: source of truth and retries without duplicates
Know beforehand
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
- What a web developer sees on the 1C side
- Publication and authentication
- OData URLs and options: $filter, $select, $expand, $top
- A Node client: reading, writing and posting
- Cyrillic object names
- 1C HTTP services: your own REST and a contract
- Conflicts, source of truth and alternatives
- Testing 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, fieldsRef_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
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.
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.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.Authentication
OData clients authenticate the same way as web service clients; the 1C:Fresh examples use an
Authorization: Basicheader with base64 login and password. Basic effectively sends the password in clear text, so the publication must be HTTPS-only.Smoke test
A plain
GETto…/odata/standard.odata/Catalog_Name/$countfrom 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, functionsstartswith,substringof,like,any/allover 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
$skipis 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.
// Литералы для $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}`;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("&")}`;
}$format=json is always setA 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.
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");
}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.
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;
}DataVersionErrors: 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 —
encodeURIComponentdoes 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.
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' }redocly lint — no errors)// Клиент собственного 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 }[] };
}
}Idempotency-Key = the website order id, a retry returns the same documentContract
- 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
codevalues: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: trueheader 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,$expandonly 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.
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{
"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"]
}.ts without a build step — .ts import extensions, erasable syntax only// Мок публикации базы 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)`);
}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`,
);
});
});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 }]);
});
});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 andJSON.parsefails - 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.
Sources
- 1C:Enterprise — REST interface (v8.1c.ru, RU)
- 1C:Enterprise — HTTP services (v8.1c.ru, RU)
- 1C:Fresh — Working with application data via the standard OData interface (RU)
- 1C:Fresh developer docs — OData integration examples (ITS, RU)
- OData Version 3.0 — URL Conventions
- OData Version 4.01 — URL Conventions (string literals)
