Т1-APIКуб Транспорт · eTRN
OpenAPI Кабинет
v1 · боевой контур работает

Электронная транспортная накладная из вашей ТМС — по одному API

Т1-API принимает подписанный титул, доводит его до оператора ЭДО, следит за перевозкой и присылает события обратно в вашу систему. Оператор, роуминг, форматы ФНС и учётки — на стороне платформы: контракт для всех операторов один. Теми же вызовами ходят заявки перевозчику и документы экспедитора.

Обзор #

Электронная транспортная накладная (ЭТрН) живёт не в одной системе: грузоотправитель подписывает титул Т1, перевозчик и водитель — свои титулы, обмен идёт через операторов ЭДО, а стороны сделки часто сидят у разных операторов. Т1-API убирает эту машинерию из вашего кода.

Один контракт на всех операторов

Контур, СберКорус, Астрал, СБИС. Маршрут выбирает платформа — или вы, кодом оператора.

Контрагента видно заранее

У каких операторов есть ящики и с какими ИдУчЭДО — до подачи, а не отказом после.

Титулы Т1–Т9 целиком

Вся цепочка перевозки, включая переадресовку и замену водителя, в одной карточке накладной.

Песочница по ключу

Тестовый ключ — и тот же боевой URL, но с моками операторов. Полный цикл до delivered за минуты.

Что вам нужно на своей стороне

  • Титул Т1 в формате ФНС 5.01 (файл в кодировке windows-1251) и отсоединённая КЭП к нему — CMS/PKCS7. Формировать титул и подписывать вы можете чем угодно: своим СКЗИ, КриптоПро, сервисом подписи.
  • API-ключ из веб-кабинета организации.
  • HTTPS-эндпоинт для вебхуков — если хотите узнавать о событиях сразу, а не поллингом.

Водительская часть цепочки (Т2, Т4 — приём груза и выдача) подписывается в мобильном приложении «Куб Транспорт» и появляется в карточке накладной автоматически. Вашей интеграции для этого делать не нужно ничего.

Быстрый старт #

Пять шагов от нуля до первой накладной в песочнице.

1. Выпустите ключ

Веб-кабинет cabinet.etrn.app → Настройки → API → «Выпустить ключ». Дайте ключу название («1С:Бухгалтерия, прод») — через полгода это единственный способ понять, какой ключ где работает. Начните с тестового: он включает песочницу целиком.

🔑
Подставить свой ключ в примеры

Значение подставится во все примеры на странице. Оно остаётся в вашем браузере: ни на сервер, ни в аналитику не уходит — на этой странице их просто нет.

2. Проверьте ключ одним запросом

GET /organization вернёт организацию, тариф и остаток лимита — заодно убедитесь, что ключ живой.

curl -sS https://api.etrn.app/v1/organization \
  -H "Authorization: Bearer $ETRN_API_KEY"
import requests

BASE_URL = "https://api.etrn.app/v1"
API_KEY = "etrn_test_…"

session = requests.Session()
session.headers["Authorization"] = f"Bearer {API_KEY}"

org = session.get(f"{BASE_URL}/organization", timeout=30).json()
print(org["name"], org["tariff"]["documents_left"])
req, _ := http.NewRequest(http.MethodGet, "https://api.etrn.app/v1/organization", nil)
req.Header.Set("Authorization", "Bearer "+apiKey) // apiKey — etrn_test_…

resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req)
if err != nil {
	return err
}
defer resp.Body.Close()
Соединение = Новый HTTPСоединение("api.etrn.app", 443, , , , 30,
	Новый ЗащищенноеСоединениеOpenSSL);

Запрос = Новый HTTPЗапрос("/v1/organization");
Запрос.Заголовки.Вставить("Authorization", "Bearer " + КлючAPI); // etrn_test_…

Ответ = Соединение.Получить(Запрос);
Сообщить(Ответ.ПолучитьТелоКакСтроку());

3. Подготовьте титул

Ядро v1 принимает готовую пару: XML титула Т1 (ФНС 5.01, windows-1251) и отсоединённую подпись CMS/PKCS7. Оба передаются строками base64 — кодируйте исходные байты файлов как есть. Если ваше СКЗИ уже отдаёт подпись base64-текстом, не кодируйте её второй раз.

4. Прогоните проверку без подачи

POST /waybills/validate — тот же конвейер проверок, что и при подаче, но ничего не создаётся и не списывается. Удобно ставить в CI титулогенератора.

POST /v1/waybills/validate
jq -n --arg xml "$XML_B64" --arg sig "$SIG_B64" '{
  signed_xml: {xml: $xml, signature: $sig},
  parties: [
    {role: "shipper",   inn: "1650152978", kpp: "165001001"},
    {role: "carrier",   inn: "7713488175"},
    {role: "consignee", inn: "7736207543"}
  ]
}' | curl -sS https://api.etrn.app/v1/waybills/validate \
      -H "Authorization: Bearer $ETRN_API_KEY" \
      -H "Content-Type: application/json" \
      --data-binary @-

# → {"valid": true, "problems": []}
# либо valid=false и problems[{field, message}] — что именно не сошлось

5. Подайте накладную и прогоните цикл

Дальше — подача и, в песочнице, simulate: накладная за минуты доходит до delivered, в цепочке появляются подписанные Т2 и Т4, а ваш обработчик вебхуков получает настоящие события.

Ключи и доступ #

Аутентификация — заголовок Authorization: Bearer <ключ> в каждом запросе. Базовый URL — https://api.etrn.app/v1, только HTTPS. Версия живёт в базовом пути; в самих ручках префикса нет.

СкоупЧто разрешает
waybills:readчитать накладные, цепочку документов, QR, справочники
waybills:writeподавать накладные и проверять титулы
webhooks:manageсоздавать, читать и удалять подписки на события

Ключ принадлежит организации, а не сотруднику: увольнение человека не гасит интеграцию. Значение показывается один раз при выпуске — платформа хранит только его хэш, «показать ещё раз» невозможно by design.

🔄
Ротация без простоя

Выпустите второй ключ → переключите на него интеграцию → отзовите старый. Оба ключа живут параллельно, пока вы переключаетесь. Обратный порядок («отозвать, потом выпустить») останавливает обмен.

Контур ключа виден в самом значении: etrn_live_… — боевой, etrn_test_… — песочница. Один и тот же код работает с обоими: меняется только ключ.

Песочница #

Тестовый ключ включает песочницу целиком — отдельного стенда, адреса и настроек не нужно.

  • Запросы идут на тот же https://api.etrn.app/v1, но обслуживаются управляемыми моками операторов: оператор sandbox подставляется сам, поле operator можно не передавать.
  • POST /waybills/{waybillId}/simulate продвигает накладную по жизненному циклу без реального водителя и оператора — включая появление подписанных Т2/Т4 в documents[].
  • POST /orders/{id}/simulate и POST /forwarding-orders/{id}/simulate изображают ответ контрагента на заявку или поручение (accepted, для заявок ещё и declined с причиной): настоящий титул Т2, УИД ГИС ЭПД, статус и вебхуки order.*/forwarding.* — как в бою.
  • Вебхуки приходят по-настоящему: обработчик отлаживается на полной цепочке событий.
  • Ни один боевой документ не расходуется и ни один рубль не списывается.
⚠️
Подписка на вебхуки — одна на оба контура

Вебхук заводится на юрлицо, а не на ключ, поэтому один и тот же URL получает события и песочницы, и боевого контура. Это удобно при отладке, но означает, что ваш прод-обработчик может получить тестовое событие.

Отличать их просто: у песочных событий operator равен "sandbox", у боевых — код реального оператора (kontur, astral, sbis, sberkorus). Проверяйте это поле перед тем, как заводить документ у себя. Либо на время отладки указывайте отдельный URL и удаляйте подписку после.

POST /v1/waybills/{waybillId}/simulate
# Прогнать цепочку до конца — события придут в вебхук как в бою
curl -sS "https://api.etrn.app/v1/waybills/$WAYBILL_ID/simulate" \
  -H "Authorization: Bearer $ETRN_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"advance_to": "delivered"}'

advance_to принимает любой статус жизненного цикла; переход назад невозможен (409). В боевом контуре ручки simulate нет вовсе — с ключом etrn_live_… она отвечает 404.

POST /v1/orders/{id}/simulate
# Перевозчик «отказал» — придут order.gis_uid_assigned и order.status_changed (declined)
curl -sS "https://api.etrn.app/v1/orders/$ORDER_ID/simulate" \
  -H "Authorization: Bearer $ETRN_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"event": "declined", "reason": "Нет свободного транспорта"}'

Ответ — документ целиком, как в GET /orders/{id}: статус accepted или declined, Т2 в titles[], УИД в gis_uid. Доступно отправителю документа со status: sent; повтор идемпотентен. Для поручений поддерживается только accepted: отказа экспедитора в формате ФНС нет.

Гарантии и повторы #

Интеграция с ЭДО ломается не на счастливом пути, а на обрывах: оператор задумался, сеть моргнула, ваш скрипт перезапустился и послал накладную второй раз. Ниже — что платформа обещает в каждом из этих случаев. Всё это проверяется нашими автотестами и живым мониторингом, а не только описано здесь.

Два уровня идемпотентности #

Повторная подача одной и той же накладной никогда не создаёт второй документ у оператора — на двух независимых уровнях:

УровеньКак работаетЗачем
Idempotency-KeyЗаголовок до 128 символов, окно 24 часа. Повтор с тем же ключом возвращает исходный ответ (200 вместо 201). Тот же ключ с другим телом — 409 idempotency-conflict.Классический сетевой ретрай: ответ не дошёл, вы повторили запрос.
ИдФайл титулаИдентификатор файла по правилу ФНС внутри XML уникален для каждого титула. Платформа ведёт по нему журнал подач бессрочно: тот же титул, поданный любым способом и с любым ключом, возвращает уже созданную накладную.Защита от дублей, когда ретрай сделала не ваша библиотека, а человек или соседняя система.

Таймаут при подаче: что уже произошло #

Подача синхронная: обычно ответ приходит за доли секунды и не позже чем через 25 секунд после того, как платформа взялась за подачу, — в этот срок входит и оформление лицензии оператора. Если к сроку запрос оператору ещё не отправлен, титул не отправляется вовсе: 503 operator-throttled, повтор безопасен. Если запрос ушёл, а ответа оператора нет, вы получаете 409 conflict с reason: submission_pending — но подача на этом не заканчивается: платформа продолжает её в фоне до полутора минут и фиксирует результат. 504 upstream-timeout — только если не ответила сама платформа (шлюз ждёт её 30 секунд).

Что вы получилиЧто произошлоЧто делать
201 / 200накладная у оператора, id в ответеничего — дальше события и карточка
504 upstream-timeout или обрыв с вашей стороныподача могла начаться; начатая идёт в фонеповторите тот же запрос через пару минут: завершённая подача вернёт 201 с той же накладной, не принятая оператором — уйдёт заново, а пока исход неизвестен — 409 с reason: submission_pending. Второго документа не будет
409 conflict, reason: submission_pendingподача ушла оператору, а ответа нет: не пришёл, оборвался или не разбирается, пришла ошибка 500/502/504 или оператор не уложился в срок ответа — исход неизвестен. Так же отвечает повтор, пока первая подача в путине повторяйте в цикле и не пересоздавайте титул с новым ИдФайл: повтор заблокирован, чтобы у оператора не появился второй документ. Подачи в Контур платформа сверяет сама — см. как. Если через пару часов ничего не изменилось, пришлите request_id в поддержку
424 operator-*оператор ждёт действия клиента (вход, права, включение API)до действия ретрай бесполезен: см. каталог ошибок, сообщите клиенту, что именно сделать. После — повторите тот же титул, он уйдёт оператору заново
503 operator-throttledдокумента у оператора нет: запрос не дошёл до него (или отправка не началась к сроку ответа — например, пока оформлялась лицензия), либо оператор явно отказал (429, 503)повторите тот же запрос через Retry-After (сейчас 120 секунд) — титул уйдёт оператору заново
503 operator-disabledплатформа отключила оператора (обслуживание, авария) — титул к нему не уходилRetry-After нет, срок знает администратор: выберите другого оператора из GET /operators или повторите, когда этот вернётся
422 validation-failedтитул или подпись не прошли проверку — оператору ничего не уходилоисправьте по errors[] и подайте заново, это новый документ

Правило одно: повторяйте тот же документ смело

Завершённая подача отдаёт свою накладную любому повтору того же титула без второй отправки — и точному повтору запроса, и титулу, подписанному заново. Если оператор не взял титул в работу — недоступен или перегружен (503 operator-throttled) либо ждёт действия клиента (424), — бронь снимается и повтор того же титула уходит оператору заново. Версию, которую оператор отклонил по существу, платформа повторно не отправляет: 409 с reason: submission_rejected, нужна новая подпись. Пока исход подачи неизвестен (запрос ушёл, ответа нет), повтор блокируется: 409 с reason: submission_pending — второй документ у оператора хуже ожидания. Для Контура такой исход платформа выясняет сама: находит документ — накладная появляется, убеждается, что его нет, — снимает блок, но не раньше чем через час после неудачной подачи.

Что платформа исправляет за вас #

Три вещи, на которых интеграции спотыкались в бою, платформа принимает и приводит к норме сама — документ доходит до оператора в том виде, который тот примет:

ОсобенностьЧто делаемПочему это важно
Подпись в BER (потоковый КриптоПро, неопределённые длины)перекодируем в строгий DER, байты подписи не трогаемКонтур отвечает на BER «CannotParseSignature», хотя подпись математически верна
Подпись закодирована base64 дваждыснимаем лишний слойтипичная ошибка при склейке ответа СКЗИ с JSON
Пробелы и переносы строк внутри base64убираемтак печатают подпись многие СКЗИ и 1С

Чего платформа не чинит: кодировку титула (файл должен быть в windows-1251), содержимое XML и сертификат подписанта. Эти проблемы возвращаются как 422 с адресом поля в errors[].

События: at-least-once #

  • Каждое событие доставляется хотя бы один раз. Обработчик обязан быть идемпотентным по event.id — повтор возможен.
  • Порядок между событиями не гарантируется: два статуса могут приехать в обратной очерёдности при ретраях. Сверяйтесь с data.waybill.status и updated_at, а не с порядком получения.
  • Ретраи: 8 попыток с паузами 1 мин → 5 мин → 15 мин → 1 ч → 3 ч → 6 ч → 12 ч — почти сутки на то, чтобы поднять ваш сервер. После восьмой неудачи подписка помечается broken; POST /webhooks/{id}/ping возвращает её в строй без пересоздания.
  • На ответ вашего сервера отводится 10 секунд. Отвечайте 2xx сразу, обрабатывайте потом.

Как меняется API #

  • Внутри v1 изменения только аддитивные: поля добавляются, существующие не меняют смысл и не исчезают. Незнакомые поля и типы событий ваш код должен пропускать молча.
  • Ломающее изменение — это новый базовый путь /v2; v1 живёт до объявленного заката, о котором мы пишем в журнале изменений и на контакт организации заранее.
  • Каждая 5xx в API поднимает дежурного: у нас алерт на любую серверную ошибку в этом контуре. В ответе всегда есть request_id — по нему запрос находится целиком, вместе с ответом оператора.

Подача ЭТрН #

POST /v1/waybills принимает подписанный титул Т1 и стороны сделки. Ответ — объект Waybill: 201, если накладная принята, 200 — если это повтор по Idempotency-Key.

Поле operator необязательно: не передали — оператора выберет платформа по правилу организации. Хотите управлять сами — передайте код из GET /operators; неизвестный или выключенный код отклоняется с 422.

# Титул и отсоединённая подпись → base64 одной строкой
XML_B64=$(base64 < t1.xml | tr -d '\n')       # титул ФНС 5.01, windows-1251
SIG_B64=$(base64 < t1.xml.sig | tr -d '\n')   # КЭП CMS/PKCS7 (DER)

jq -n --arg xml "$XML_B64" --arg sig "$SIG_B64" '{
  signed_xml: {xml: $xml, signature: $sig},
  parties: [
    {role: "shipper",   inn: "1650152978", kpp: "165001001"},
    {role: "carrier",   inn: "7713488175"},
    {role: "consignee", inn: "7736207543"}
  ],
  external_ref: "TMS-2026-000123"
}' | curl -sS https://api.etrn.app/v1/waybills \
      -H "Authorization: Bearer $ETRN_API_KEY" \
      -H "Content-Type: application/json" \
      -H "Idempotency-Key: $(uuidgen)" \
      --data-binary @-
"""Подача ЭТрН (титул Т1). Зависимости: pip install requests."""
import base64, uuid, requests

BASE_URL = "https://api.etrn.app/v1"
API_KEY = "etrn_test_…"

session = requests.Session()
session.headers["Authorization"] = f"Bearer {API_KEY}"


class EtrnProblem(Exception):
    """Ошибка API в формате RFC 9457 (application/problem+json)."""

    def __init__(self, status: int, problem: dict):
        self.status = status
        self.type = problem.get("type", "about:blank")   # машинный классификатор
        self.detail = problem.get("detail", "")
        self.request_id = problem.get("request_id", "")  # в обращение в поддержку
        self.errors = problem.get("errors", [])           # пополевые детали при 422
        super().__init__(f"{status} {self.detail} (request_id={self.request_id})")


def raise_for_problem(resp: requests.Response) -> None:
    if resp.ok:
        return
    if resp.headers.get("Content-Type", "").startswith("application/problem+json"):
        raise EtrnProblem(resp.status_code, resp.json())
    resp.raise_for_status()


def submit_waybill(xml_path: str, sig_path: str) -> dict:
    with open(xml_path, "rb") as f:   # титул ФНС 5.01, windows-1251
        xml_bytes = f.read()
    with open(sig_path, "rb") as f:   # отсоединённая КЭП (CMS/PKCS7, DER)
        sig_bytes = f.read()

    resp = session.post(
        f"{BASE_URL}/waybills",
        json={
            "signed_xml": {
                "xml": base64.b64encode(xml_bytes).decode(),
                "signature": base64.b64encode(sig_bytes).decode(),
            },
            "parties": [
                {"role": "shipper", "inn": "1650152978", "kpp": "165001001"},
                {"role": "carrier", "inn": "7713488175"},
                {"role": "consignee", "inn": "7736207543"},
            ],
            "external_ref": "TMS-2026-000123",   # номер рейса у вас
        },
        headers={"Idempotency-Key": str(uuid.uuid4())},  # безопасный повтор 24 ч
        timeout=30,
    )
    raise_for_problem(resp)   # 403 — юрлицо не ваше, 422 — титул не прошёл
    return resp.json()    # 201 — принята, 200 — повтор по ключу идемпотентности
// Подача ЭТрН (титул Т1) — стандартная библиотека, без зависимостей.
const baseURL = "https://api.etrn.app/v1"

type SignedXML struct {
	XML       string `json:"xml"`       // титул ФНС 5.01 (windows-1251), base64
	Signature string `json:"signature"` // отсоединённая КЭП CMS/PKCS7, base64
}

type Party struct {
	Role string `json:"role"` // shipper | carrier | consignee
	INN  string `json:"inn"`
	KPP  string `json:"kpp,omitempty"`
}

// Problem — ошибка API в формате RFC 9457.
type Problem struct {
	Type      string `json:"type"`
	Title     string `json:"title"`
	Detail    string `json:"detail"`
	RequestID string `json:"request_id"`
}

func (p Problem) Error() string {
	return fmt.Sprintf("%s: %s (request_id=%s)", p.Title, p.Detail, p.RequestID)
}

func submitWaybill(apiKey string, xml, sig []byte) (*Waybill, error) {
	payload, err := json.Marshal(CreateWaybillRequest{
		SignedXML: SignedXML{
			XML:       base64.StdEncoding.EncodeToString(xml),
			Signature: base64.StdEncoding.EncodeToString(sig),
		},
		Parties: []Party{
			{Role: "shipper", INN: "1650152978", KPP: "165001001"},
			{Role: "carrier", INN: "7713488175"},
			{Role: "consignee", INN: "7736207543"},
		},
		ExternalRef: "TMS-2026-000123",
	})
	if err != nil {
		return nil, err
	}

	req, err := http.NewRequest(http.MethodPost, baseURL+"/waybills", bytes.NewReader(payload))
	if err != nil {
		return nil, err
	}
	req.Header.Set("Authorization", "Bearer "+apiKey)
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Idempotency-Key", idempotencyKey()) // повтор безопасен 24 ч

	resp, err := (&http.Client{Timeout: 30 * time.Second}).Do(req)
	if err != nil {
		return nil, err
	}
	defer resp.Body.Close()

	// 201 — принята, 200 — повтор по Idempotency-Key
	if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK {
		var p Problem // 403 — юрлицо не ваше, 422 — титул не прошёл проверку
		if err := json.NewDecoder(resp.Body).Decode(&p); err != nil {
			return nil, fmt.Errorf("HTTP %d", resp.StatusCode)
		}
		return nil, p
	}
	var wb Waybill
	if err := json.NewDecoder(resp.Body).Decode(&wb); err != nil {
		return nil, err
	}
	return &wb, nil
}
// Подача ЭТрН (титул Т1). Платформа 8.3.

Функция Базовый64(ПутьКФайлу)
	// Base64Строка вставляет переводы строк — для JSON их нужно убрать
	Строка64 = Base64Строка(Новый ДвоичныеДанные(ПутьКФайлу));
	Строка64 = СтрЗаменить(Строка64, Символы.ВК, "");
	Строка64 = СтрЗаменить(Строка64, Символы.ПС, "");
	Возврат Строка64;
КонецФункции

Функция ПодатьНакладную(КлючAPI, ПутьКXML, ПутьКПодписи)

	ПодписанныйXML = Новый Структура;
	ПодписанныйXML.Вставить("xml", Базовый64(ПутьКXML));            // ФНС 5.01, windows-1251
	ПодписанныйXML.Вставить("signature", Базовый64(ПутьКПодписи)); // КЭП CMS/PKCS7

	Стороны = Новый Массив;
	Грузоотправитель = Новый Структура("role, inn, kpp", "shipper", "1650152978", "165001001");
	Стороны.Добавить(Грузоотправитель);
	Перевозчик = Новый Структура("role, inn", "carrier", "7713488175");
	Стороны.Добавить(Перевозчик);
	Грузополучатель = Новый Структура("role, inn", "consignee", "7736207543");
	Стороны.Добавить(Грузополучатель);

	Тело = Новый Структура;
	Тело.Вставить("signed_xml", ПодписанныйXML);
	Тело.Вставить("parties", Стороны);
	Тело.Вставить("external_ref", "TMS-2026-000123"); // номер рейса в вашей системе

	ЗаписьJSON = Новый ЗаписьJSON;
	ЗаписьJSON.УстановитьСтроку();
	ЗаписатьJSON(ЗаписьJSON, Тело);
	СтрокаТела = ЗаписьJSON.Закрыть();

	Запрос = Новый HTTPЗапрос("/v1/waybills");
	Запрос.Заголовки.Вставить("Authorization", "Bearer " + КлючAPI);
	Запрос.Заголовки.Вставить("Content-Type", "application/json");
	// Безопасный повтор 24 ч: тот же ключ вернёт исходный результат
	Запрос.Заголовки.Вставить("Idempotency-Key", Строка(Новый УникальныйИдентификатор));
	Запрос.УстановитьТелоИзСтроки(СтрокаТела, КодировкаТекста.UTF8);

	Соединение = Новый HTTPСоединение("api.etrn.app", 443, , , , 30,
		Новый ЗащищенноеСоединениеOpenSSL);
	Ответ = Соединение.ОтправитьДляОбработки(Запрос);
	СтрокаОтвета = Ответ.ПолучитьТелоКакСтроку();

	// 201 — принята, 200 — повтор. Иначе — problem+json: type/detail/request_id
	Если Ответ.КодСостояния <> 201 И Ответ.КодСостояния <> 200 Тогда
		ВызватьИсключение "eTRN " + Ответ.КодСостояния + ": " + СтрокаОтвета;
	КонецЕсли;

	ЧтениеJSON = Новый ЧтениеJSON;
	ЧтениеJSON.УстановитьСтроку(СтрокаОтвета);
	Возврат ПрочитатьJSON(ЧтениеJSON, Истина);

КонецФункции

Ответ

201 Created · Waybill
{
  "id": "0b3c6c1e-8b7e-4f7a-9d2e-5a1f1c9e4b7d",
  "status": "pending_receipt",
  "number": "ТрН-2026-000123",
  "date": "2026-08-16",
  "external_ref": "TMS-2026-000123",
  "operator": "kontur",
  "created_at": "2026-08-16T10:21:33Z"
}
🧾
Идемпотентность

Передавайте Idempotency-Key при каждой подаче. Повтор с тем же ключом в течение 24 часов вернёт исходный результат (200 вместо 201) — сеть, таймаут и рестарт вашей очереди не превратятся во вторую накладную. Тот же ключ с другим телом — 409.

Подпись титула: требования и типичные причины 422 #

Платформа принимает готовую пару «XML титула + отсоединённая подпись». Ниже — что должно быть на входе, чтобы титул прошёл и нашу проверку, и оператора. Каждый пункт — из живых интеграций.

ТребованиеКак правильно
Формат титулаЭТрН по формату ФНС 5.01, титул Т1 грузоотправителя. Файл в кодировке windows-1251, как требует формат.
ИдФайлСтроится по правилу ФНС и уникален для каждого титула. Он же — ключ идемпотентности: новый документ = новый ИдФайл.
ИНН отправителяИНН в титуле должен совпадать с parties[role=shipper].inn и принадлежать организации ключа (или юрлицу группы).
ПодписьОтсоединённая КЭП в контейнере CMS/PKCS#7, ГОСТ Р 34.10-2012. Предпочтителен DER; BER принимается и перекодируется.
СертификатКвалифицированный сертификат организации-отправителя. Если подписывает представитель по МЧД — реквизиты доверенности должны стоять в титуле (блок СвДовер).
КодированиеОба поля — base64 от исходных байтов файлов. Если СКЗИ уже отдало base64, не кодируйте второй раз (мы это распознаём, но лучше не полагаться).
РазмерТитул до 2 МБ, всё тело запроса до 8 МБ.

Типичные причины 422 и как их найти за минуту #

Что в errors[]ПричинаПроверка
signed_xml.xml — «титул не читается»файл не XML ФНС, другая кодировка, обрезаноткройте файл: первая строка должна объявлять windows-1251; прогоните через POST /waybills/validate
signed_xml.xml — «это не титул Т1»подан Т2/Т3 или чужой вид документаИдФайл начинается с ON_TRNACLGROT_ — это Т1
parties — «ИНН отправителя в титуле не совпадает»титул сформирован от одного юрлица, а parties указывает другоесверьте ИНН в титуле и в parties[role=shipper]
signed_xml.signature — «подпись не соответствует документу»подписан другой файл: XML пересохранили после подписания, добавился BOM или перенос строкиподписывайте ровно те байты, что отправляете; не пропускайте файл через редактор после подписи
signed_xml.signature — «контейнер не разобран»в поле не CMS: PEM с заголовками, сертификат вместо подписи, присоединённая подпись с документом внутринужна отсоединённая CMS/PKCS#7; PEM-заголовки уберите
signed_xml.signature — «ИНН подписанта не совпадает с отправителем»подписано сертификатом другой организации или сотрудника без МЧДсертификат юрлица-отправителя, либо представитель с МЧД и блоком СвДовер в титуле
operator — «оператор неизвестен или выключен»код не из GET /operators или отключённе передавайте поле вовсе — платформа выберет сама
errors[] пуст, title — «Оператор ЭДО отклонил документ»титул дошёл до оператора, и он отказал по существу: у плательщика закончился пакет, поля титула, подпись, титул адресован другому операторупричина и что сделать — в detail, его можно показать человеку как есть; исправьте и подайте новой подписью

Проверяйте в CI, а не на бою

POST /waybills/validate гоняет ровно тот же конвейер, что и подача, включая криптопроверку подписи, но ничего не создаёт и не списывает. Поставьте его в тесты титулогенератора: ошибка формата будет видна до того, как её увидит клиент.

Статусы перевозки #

submitting→ pending_receipt→ in_transit→ pending_delivery→ delivered ·failed cancelled
СтатусЧто означает
submittingнакладная принята платформой, но ещё не дошла до pending_receipt. В ответе на подачу не встречается: недоступный или перегруженный оператор — это 503 operator-throttled, повтор за вами
pending_receiptтитул у оператора, ждём приёма груза водителем (Т2)
in_transitгруз принят, машина в пути
pending_deliveryприбытие к грузополучателю, ждём выдачи (Т4)
deliveredперевозка завершена, цепочка титулов закрыта
failedтерминальный: причина в failure {code, message}, списанная подпись возвращается — исправленный титул подаётся новой накладной
cancelledстороны аннулировали поток на стороне оператора

При обычной синхронной подаче submitting вы не увидите — ответ сразу pending_receipt. Статус awaiting_signature относится к версии v1.1 (черновик + двухфазная подпись) и в ядре не встречается.

Титулы Т1–Т9 #

Все титулы перевозки приезжают в documents[] карточки накладной — с XML, подписью и временем. Вам не нужно знать, какой оператор их принёс и в каком формате они лежали у него.

ТитулКто подписываетКогда появляется
T1грузоотправительваша подача — начало цепочки
T2водитель (приём груза)погрузка; в приложении «Куб Транспорт»
T3грузоотправительподтверждение приёма груза к перевозке
T4водитель (выдача груза)выгрузка у грузополучателя
T5грузополучательприём груза получателем
T6грузоотправитель / перевозчикуточнение условий перевозки
T7перевозчикпереадресовка — смена пункта выгрузки
T8перевозчикзамена водителя или транспортного средства
T9стороныслужебные отметки цепочки
✍️
Подписывать Т2–Т9 из API не нужно

Водительские титулы подписываются в мобильном приложении, титулы контрагентов — у них. Ваша интеграция подаёт Т1 и читает остальное: подписка на waybill.document_added сообщит о каждом новом документе цепочки.

🆔
УИД в ГИС ЭПД приходит позже — и это нормально

Накладная получает УИД не в момент подачи: Т1 уходит оператору без него, а идентификатор присваивает государство. Поэтому в ответе на создание поле gis_uid пустое, и ждать его не нужно — подавайте следующие накладные сразу, хоть тысячу подряд.

Когда УИД назначен, приходит событие waybill.gis_uid_assigned с готовым значением в data.waybill.gis_uid. Тот же УИД потом виден в карточке накладной. Он один на всю цепочку Т1→Т2→Т3→Т4, так что по нему удобно связывать документы у себя.

Операторы ЭДО: что важно знать #

Контракт один, но за ним четыре оператора с разным характером. Платформа скрывает различия, где это возможно; здесь — то, что всё же полезно знать интегратору, чтобы читать ответы и объяснять клиентам.

ОператорКодИдентификатор абонентаОсобенности
Контур (Диадок, Логистика)kontur2BM-…Клиент авторизует свой кабинет Контура в нашем кабинете один раз. Если доступ отозван (смена пароля, вход с другого устройства), подача отвечает 424 operator-reauth-required — клиенту нужно войти заново. Подписать титул может только участник накладной: ИНН и ящик отправителя в титуле должны быть его.
СберКорусsberkorus2BK-…Подключение оформляется письмом в отдел настройки оператора, платформа делает это за клиента. Т2 и Т4 водителей приходят через несколько секунд после подписания в приложении.
Астрал (Калуга Астрал)astral2AE… (GUID)Получатели документа берутся из ИдФайл титула, если вы не передали operator_subscriber_id в parties. Ответ оператора приходит событием и может занять до минуты — это тот случай, когда бывает 504 и работает повтор без дублей.
СБИС (Тензор)sbis2BE…Обмен идёт от аккаунта клиента, которому наша организация должна быть выдана в правах. Права отозваны — 424 operator-access-revoked; обмен по API не включён у оператора — 424 operator-api-not-enabled.

Идентификаторы участников ЭДО #

participant_id в GET /organization и GET /counterparties/{inn} — это идентификатор участника ЭДО по ФНС: первые три символа обозначают оператора (2AE Астрал, 2BM Контур, 2BK СберКорус, 2BE Тензор, 2AL Такском и так далее — распознаётся весь действующий реестр операторов ФНС, а не только те четыре, через которых возит платформа). У организации может быть несколько идентификаторов — по одному у каждого оператора, а иногда и несколько ящиков у одного. Все они приходят в participant_ids, а с именем оператора и признаком «наш» — в массиве operators. Этот же идентификатор стоит в ИдФайл титула.

Роуминг и достижимость #

GET /counterparties/{inn} отвечает заранее: найдена ли организация в ЭДО, у каких операторов и с какими идентификаторами. Проверяйте перевозчика и грузополучателя до подачи. reachable: true означает «организация найдена в реестре ЭДО», а не «документ до неё дойдёт»: наличие ИдУчЭДО не доказывает ни того, что у контрагента подключён именно ЭПД, ни того, что роуминг с его оператором работает в продуктивной среде. Между Контуром, Астралом, Тензором и СберКорусом ЭТрН в роуминге работает; за их пределами — уточняйте у контрагента, какой ящик принимает ЭТрН.

Связь с оператором может пропасть без вашего участия

Токен клиента у оператора может протухнуть, права — быть отозваны. Платформа пишет клиенту письмо с точным действием в момент потери связи и второе — когда обмен восстановлен. Вам как интегратору приходит честный 424 с кодом причины: покажите его текст клиенту, а не повторяйте запрос по расписанию.

Документы и QR #

GET /v1/waybills/{waybillId} отдаёт карточку с массивом documents[]. Содержимое каждого документа скачивается отдельно — XML или отсоединённая подпись:

Скачать XML титула и его подпись
# id из ответа на подачу — наш UUID накладной (он же waybillId в путях). Это НЕ УИД_ТрН:
# УИД_ТрН придёт в карточке полем gis_uid, когда оператор зарегистрирует документ в ГИС ЭПД
WAYBILL_ID=$(jq -r .id waybill.json)

# Карточка: статус + цепочка документов (Т2/Т4 водителя появятся здесь же)
curl -sS "https://api.etrn.app/v1/waybills/$WAYBILL_ID" \
  -H "Authorization: Bearer $ETRN_API_KEY" -o card.json

# Ид документа цепочки — из карточки: documents[].id; вид — documents[].kind (T1, T2, T4 …)
DOC_ID=$(jq -r '.documents[] | select(.kind=="T2") | .id' card.json | head -1)

# XML документа цепочки и отсоединённая подпись к нему
curl -sS "https://api.etrn.app/v1/waybills/$WAYBILL_ID/documents/$DOC_ID/content?part=xml" \
  -H "Authorization: Bearer $ETRN_API_KEY" -o t2.xml
curl -sS "https://api.etrn.app/v1/waybills/$WAYBILL_ID/documents/$DOC_ID/content?part=sig" \
  -H "Authorization: Bearer $ETRN_API_KEY" -o t2.xml.sig

# QR подтверждения перевозки — показать инспектору на дороге (PNG)
curl -sS "https://api.etrn.app/v1/waybills/$WAYBILL_ID/qr" \
  -H "Authorization: Bearer $ETRN_API_KEY" -o qr.png
Какой идентификатор для чего

id — наш UUID накладной: выдаётся сразу при подаче, не меняется, стоит в путях как waybillId и в вебхуках.

gis_uid — УИД_ТрН в ГИС ЭПД: его присваивает оператор при регистрации Т1, поэтому в ответе на POST его ещё нет; появляется в карточке и в вебхуке и дальше постоянен для Т1–Т4 — им сверяются с ГИС ЭПД и с ЭДО контрагента.

documents[].id — ид документа цепочки, только для скачивания content.

operator_waybill_id — ид перевозки у оператора ЭДО, для сверки на его стороне.

external_ref — ваш номер рейса из ТМС, по нему есть фильтр в списке.

Заявки и экспедиторские документы #

До накладной есть ещё документы. Заказчик присылает перевозчику заявку, перевозчик соглашается или отказывает. Клиент поручает груз экспедитору, экспедитор подтверждает приём расписка. С 12 октября 2026 эти документы ходят по тому же API, что ЭТрН, и по тем же принципам: один контракт для всех операторов ЭДО, роуминг и форматы ФНС — на стороне платформы. Отличие одно, и оно в вашу пользу: титул собираем мы. Вы передаёте форму в JSON, платформа строит XML по формату ФНС, проверяет его и отдаёт байты на подпись.

ДокументКто → комуТитулыБазовый путь
Заявка на перевозку (ЭЗЗ)заказчик — грузоотправитель или экспедитор → перевозчикТ1 заявка · Т2 согласие или отказ перевозчика/orders
Поручение экспедитору (ЭПЭ)клиент → экспедиторТ1 поручение · Т2 согласие экспедитора · Т3 отзыв клиентом/forwarding-orders
Экспедиторская расписка (ЭЭР)экспедитор → клиентодин титул/forwarding-receipts
Складская расписка (ЭСР)экспедитор → клиентодин титул/forwarding-receipts с kind: warehouse_receipt
Подпись ставится на байты XML из ответа

Декодируйте title.xml из base64 и подписывайте ровно эти байты отсоединённой КЭП (CAdES-BES, как для накладной — см. требования к подписи). title.sha256 — чтобы сверить, что подписали то, что собрано. Поправили форму — соберите титул заново: старая подпись к новым байтам не подойдёт, подача ответит 409 title-changed.

Ключи и доступ — те же. Для заявок нужен скоуп orders:read / orders:write, для экспедиции — forwarding:read / forwarding:write; ключи со скоупами waybills:* проходят без перевыпуска. Сторона документа действует от юрлица ключа (у группового — от любого подтверждённого участника, укажите org_inn); право подписи за юрлицо даёт его подтверждённая связка с оператором ЭДО. Тарификация — по подписям: каждый поданный титул списывает одну подпись у организации, которая его подписала.

Песочница: тестовый ключ подаёт в sandbox

С тестовым etrn_test_-ключом документ уходит мок-оператору sandbox (operator подставляется сам), подписи не списываются, после подачи документ получает status: sent. Так проверяется вся ваша сторона: форма, сборка титула, подпись байтов, подача. Ответ контрагента изображает POST /v1/orders/{id}/simulate (для поручений — POST /v1/forwarding-orders/{id}/simulate): платформа собирает настоящий титул Т2 от лица контрагента, документ получает УИД ГИС ЭПД и статус accepted или declined, вебхуки и цепочка — как в бою. Боевые документы сейчас ходят через Астрал и СберКорус; СБИС и Контур — следующим этапом.

Создать, подписать, подать #

Три вызова. POST /v1/orders сохраняет черновик по вашей форме и сразу собирает титул Т1: в ответе document, title.xml (base64), title.sha256 и title.prepared_title_id. Вы подписываете XML и отдаёте подпись в POST /v1/orders/{id}/titles/t1/submit. Документ уходит оператору ЭДО и дальше живёт сам: awaiting_operator → sent → accepted или declined.

order.json — форма заявки (snake_case)
{
  "external_ref": "TMS-48213",
  "signer": { "full_name": "Смирнов Алексей Викторович", "position": "Генеральный директор" },
  "form": {
    "number": "ЗЗ-48213", "date": "2026-10-12", "function": "Заказ",
    "customer":  { "inn": "7713488175", "kpp": "771301001", "name": "ООО «Ромашка»" },
    "carrier":   { "inn": "1650152978", "name": "ООО «Василёк»" },
    "consignee": { "inn": "5009087117", "name": "ООО «Дочка»" },
    "loading":  { "country_code": "643", "text": "Москва, ул. Складская, 1" },
    "delivery": { "country_code": "643", "text": "Казань, ул. Приёмная, 7" },
    "loading_at": "2026-10-14T09:00:00+03:00",
    "cargo": [ { "name": "Косметика в коробах", "places": 24, "gross_kg": "1200.5", "volume_m3": "12.4" } ],
    "vehicle": { "type": "Тентованный полуприцеп", "capacity_t": "20", "volume_m3": "82" },
    "transport_rules": "Не кантовать",
    "food_requirements": "Не требуется"
  }
}
# 1. Черновик + титул Т1 на подпись. Idempotency-Key защищает от сетевого повтора,
#    external_ref в форме — от второго документа по той же заявке ТМС (409 external-ref-taken)
curl -sS -X POST https://api.etrn.app/v1/orders \
  -H "Authorization: Bearer $ETRN_API_KEY" \
  -H "Content-Type: application/json" -H "Idempotency-Key: $(uuidgen)" \
  -d @order.json -o created.json

# 422? Черновик всё равно сохранён: .document_id, поля — .errors[]. Поправьте PATCH-ем и соберите …/send
ORDER_ID=$(jq -r .document.id created.json)
jq -r .title.xml created.json | base64 -d > t1.xml
jq -r .title.sha256 created.json; shasum -a 256 t1.xml   # должны совпасть

# 2. Подпись — отсоединённая CAdES-BES вашим СКЗИ сертификатом организации-заказчика → t1.xml.sig

# 3. Подача: документ уходит оператору ЭДО перевозчика
curl -sS -X POST "https://api.etrn.app/v1/orders/$ORDER_ID/titles/t1/submit" \
  -H "Authorization: Bearer $ETRN_API_KEY" \
  -H "Content-Type: application/json" \
  -d "{\"signature\": \"$(base64 < t1.xml.sig | tr -d '\n')\"}"

# Статус и ответ перевозчика — в карточке: .status, .stage, .decline.reason
curl -sS "https://api.etrn.app/v1/orders/$ORDER_ID" -H "Authorization: Bearer $ETRN_API_KEY"
import base64, json, uuid, requests

BASE_URL = "https://api.etrn.app/v1"
API_KEY = "etrn_test_…"
session = requests.Session()
session.headers["Authorization"] = f"Bearer {API_KEY}"

with open("order.json", encoding="utf-8") as f:
    order = json.load(f)

r = session.post(f"{BASE_URL}/orders", json=order, headers={"Idempotency-Key": str(uuid.uuid4())})
if r.status_code == 422:
    problem = r.json()  # черновик сохранён: problem["document_id"]; что поправить — problem["errors"]
    raise SystemExit(problem["errors"])
if r.status_code == 409 and r.json().get("existing_id"):
    raise SystemExit(f"уже есть: {r.json()['existing_id']}")
r.raise_for_status()
created = r.json()

xml_bytes = base64.b64decode(created["title"]["xml"])
signature = sign_detached(xml_bytes)  # ваше СКЗИ: отсоединённая CAdES-BES, DER

doc_id = created["document"]["id"]
r = session.post(f"{BASE_URL}/orders/{doc_id}/titles/t1/submit", json={
    "signature": base64.b64encode(signature).decode(),
    "prepared_title_id": created["title"]["prepared_title_id"],
})
r.raise_for_status()
print(r.json()["document"]["status"])  # awaiting_operator → sent → accepted | declined
Что обязательно в форме — подскажет сборщик

Правила формата ФНС зашиты в платформу: пропущенное или неверное поле вернётся в errors[] (422) или в problems[] карточки с путём в snake_case — cargo[0].gross_kg, vehicle.type, loading_at. Числа передаются строками с точкой, как в XML; даты — YYYY-MM-DD; loading_at — с часовым поясом. Для function: "Заявка" нужен договор об организации перевозок (contract). Полные схемы форм — в OpenAPI.

Ответ перевозчика: принять или отклонить #

Входящие заявки — GET /v1/orders?direction=incoming&stage=your_turn. Принять — POST /v1/orders/{id}/accept с водителями, транспортом и ценой; отклонить — …/decline с причиной. В обоих случаях в ответе титул Т2 на подпись, подача — …/titles/t2/submit. Заказчик увидит status: accepted или declined и причину в decline.reason; у него же появится available_actions для следующего шага.

Согласие перевозчика
curl -sS -X POST "https://api.etrn.app/v1/orders/$ORDER_ID/accept" \
  -H "Authorization: Bearer $ETRN_API_KEY" -H "Content-Type: application/json" \
  -d '{
    "signer": { "full_name": "Петров Пётр Петрович", "position": "Директор" },
    "form": {
      "drivers": [ { "last_name": "Иванов", "first_name": "Иван", "patronymic": "Иванович",
                     "phone": "+79990000000", "license_series": "9912", "license_number": "123456", "license_date": "2019-05-20" } ],
      "vehicle":  { "plate": "А123ВС77", "type": "Тягач", "brand": "КАМАЗ", "ownership": "1" },
      "trailers": [ { "plate": "АВ123477", "type": "Полуприцеп тентованный" } ],
      "price_without_vat": "45000.00", "price_with_vat": "54000.00", "vat_rate": "20%",
      "payment_terms": "Оплата в течение 5 банковских дней после доставки"
    }
  }' -o t2.json
# Дальше как с Т1: jq -r .title.xml t2.json | base64 -d > t2.xml → подписать → POST …/titles/t2/submit

После согласия заказчик выписывает накладную. POST /v1/orders/{id}/waybill-form заводит черновик ЭТрН и переносит в него груз, стороны, водителя и транспорт из заявки и согласия — в ответе форма черновика и problems[], чего не хватает; черновик виден и в веб-кабинете. Накладную, поданную через POST /waybills как обычно, свяжите с заявкой: POST /v1/orders/{id}/link-waybill с waybill_id — связь появится в links обеих карточек и в цепочке.

Поручение экспедитору и расписка #

Клиент создаёт поручение — POST /v1/forwarding-orders (титул Т1), экспедитор принимает — …/accept (Т2). Отказа от поручения в формате ФНС нет: вместо него клиент отзывает поручение — …/revoke (Т3) с датой, причиной и суммой фактических расходов экспедитора.

По принятому поручению экспедитор одним вызовом заводит следующий документ «на основании»: расписку клиенту — POST /v1/forwarding-orders/{id}/receipt, заявку перевозчику — …/order, поручение следующему экспедитору — …/forward. Стороны, договор, груз, адреса и УИД исходного поручения уже перенесены; допишите недостающее PATCH-ем и соберите титул POST …/{id}/send, дальше подпись и подача как обычно.

Признак экспедитора

Получатель поручения должен быть экспедитором — это требование формата, и оператор ЭДО его проверяет. Признак ставится в профиле организации в веб-кабинете; без него ответ на поручение отвечает 409 forwarder-required. Заявке перевозчику от экспедитора нужен customer_role: "forwarder" — у документа «на основании» он уже стоит.

Расписка бывает двух видов. Экспедиторская (ЭЭР) — груз принят во владение для перевозки. Складская (ЭСР) — груз принят на хранение: в форме дополнительно склад с адресом и владельцем (если склад не экспедитора), условия приёма — дата и время, срок хранения, состояние груза, тары, упаковки, маркировки и пломб — и кто фактически принял груз, с документом о его полномочиях. Оба вида живут в одном ресурсе /v1/forwarding-receipts: при создании передайте kind: "warehouse_receipt", по поручению — POST …/receipt с телом {"kind":"warehouse_receipt"}. Список отдаёт оба вида, ?kind=warehouse_receipt сужает до складских.

warehouse-receipt.json — складская расписка (snake_case)
{
  "kind": "warehouse_receipt",
  "signer": { "full_name": "Петров Пётр Петрович", "position": "Директор склада" },
  "form": {
    "number": "СР-12", "date": "2026-10-12",
    "client":    { "inn": "7707083893", "kpp": "770701001", "name": "ООО \"Ромашка\"" },
    "forwarder": { "inn": "7731162754", "kpp": "773101001", "name": "ООО \"АвтоТрансЛогистик\"" },
    "contract": { "name": "Договор транспортной экспедиции", "number": "ТЭ-14", "date": "2026-02-01" },
    "parent_gis_uid": "9c8d7e6f-0000-4000-8000-000000000007",
    "warehouse": {
      "address": { "country_code": "643", "country_name": "Россия", "text": "300012, Тула, ул. Складская, 5" },
      "comment": "Въезд со двора, ворота 3",
      "owned_by_forwarder": true              // false — заполните owner: владелец склада
    },
    "storage": {
      "received_date": "2026-10-12", "received_time": "10:30:00", "storage_days": "30",
      "cargo_condition": "Упаковка целая, без повреждений", "seals_condition": "Пломбы не нарушены"
      // acceptor + acceptor_authority — если груз принимал сотрудник: лицо и документ о полномочиях, только вместе
    },
    "delivery_method": "Автомобильным транспортом до склада", "remarks": "Без замечаний",
    "cargo": [{ "name": "Косметика в коробках", "marking": "Партия 17", "places": 36, "gross_kg": "300", "net_kg": "280", "origin_country_code": "643", "origin_country_name": "Россия" }]
  }
}

Исправление расписки #

По правилам обмена экспедиторскими документами менять их по своей инициативе нельзя. Исправленный файл формируют только по запросу ГИС ЭПД: если она нашла расхождения, оператор ЭДО сообщает об этом, и исправление нужно отправить не позже пяти календарных дней. В API такая расписка приходит в статусе failed с УИД и причиной отказа. POST /v1/forwarding-receipts/{id}/correct заводит черновик того же документа с блоком correction (ИспрФО, УИД прежний): форма скопирована из подписанного титула. От вас — поправить то, на что указала ГИС, задать correction.date через PATCH, затем POST …/send, подпись и подача как обычно.

исправление: черновик → правки → титул
# 1. черновик исправления расписки, отклонённой ГИС ЭПД
curl -sS -X POST https://api.etrn.app/v1/forwarding-receipts/$RECEIPT_ID/correct \
  -H "Authorization: Bearer $ETRN_API_KEY" -o fix.json          # 201, document.id — исправление
# 2. правки по замечанию ГИС и дата исправления
curl -sS -X PATCH https://api.etrn.app/v1/forwarding-receipts/$FIX_ID \
  -H "Authorization: Bearer $ETRN_API_KEY" -H "Content-Type: application/json" \
  -d '{ "form": { "correction": { "date": "2026-10-14" }, "storage": { "cargo_condition": "Повреждена упаковка двух мест" } } }'
# 3. титул на подпись — и дальше titles/t1/submit, как у первой версии
curl -sS -X POST https://api.etrn.app/v1/forwarding-receipts/$FIX_ID/send \
  -H "Authorization: Bearer $ETRN_API_KEY" -H "Content-Type: application/json" \
  -d '{ "signer": { "full_name": "Петров Пётр Петрович", "position": "Директор склада" } }'
  • Доставленную или принятую расписку (sent / accepted) заменить по своей инициативе нельзя: …/correct отвечает 409 receipt-replacement-off. Формат ФНС (приказ ЕД-1-26/277@) замену предусматривает (УИД_ПредЭкспР / УИД_ПредСкладР), но платформа её не выписывает, пока не подтверждено основание.
  • Исправляется только последняя версия: повторный …/correct до отправки возвращает тот же черновик, а по уже исправленной расписке отвечает 409 action-not-allowed — работайте с новой.
  • Расписка без УИД (оператор ещё не зарегистрировал её в ГИС) — 409 gis-uid-pending: дождитесь события forwarding.gis_uid_assigned.
  • В GET /v1/chain исправление связано с исходной распиской отношением corrects. Если контрагент сам выписал замену своей расписки, она придёт отдельным документом по тому же поручению: связь replaces, в form стоит replaces_gis_uid, прежняя расписка остаётся в истории.
  • Каждое исправление — отдельный титул и отдельная подпись, тарифицируется как расписка.

Цепочка документов #

GET /v1/chain собирает всё, что связано с одним звеном: передайте ровно один параметр — order_id, forwarding_order_id, forwarding_receipt_id или waybill_id. В ответе documents[], links[] (relation: receipt — поручение → расписка, order — поручение → заявка, transportation — заявка → накладная) и waybills[] со статусом перевозки. Чужие звенья, где ваше юрлицо не сторона, в цепочку не попадают.

Статусы и этапы #

status — что с документом в целом, stage — чей сейчас ход с вашей точки зрения. Очередь «что требует действия» — это GET …?stage=your_turn; action_since говорит, с какого момента ход за вами, available_actions[] — что именно можно сделать.

statusЧто этоstage у отправителя / получателя
draftчерновик, титул не собранdraft / —
preparedтитул собран и ждёт подписиyour_turn / —
awaiting_operatorподпись принята, оператор ЭДО обрабатываетwaiting / —
failedоператор отклонил титул: причина в titles[].failure, поправьте и соберите зановоyour_turn / —
sentдокумент у получателя, ждёт его ответаwaiting / your_turn
acceptedполучатель согласился (Т2 с согласием)done / done
declinedперевозчик отказал (Т2 с отказом), причина в decline.reasondeclined / declined
revokedклиент отозвал поручение (Т3)revoked / revoked

О движении заявок и экспедиторских документов сообщают вебхуки order.* и forwarding.*: документ приходит в теле события в той же форме, что и в карточке, глазами вашей стороны. Ошибки этих ручек описаны в общей таблице ошибок.

Вебхуки #

Подписка — POST /webhooks с url (только https) и списком событий. Пустой список означает «все события». secret возвращается один раз в ответе на создание: список подписок секретов не отдаёт. Утеряли — удалите подписку и создайте новую; это же и штатная ротация секрета.

СобытиеКогда приходит
waybill.createdнакладная принята платформой
waybill.status_changedсменился статус: data.previous_status → data.waybill.status
waybill.document_addedв цепочке новый документ, например подписанный Т2 или Т4 (data.document)
waybill.gis_uid_assignedГИС ЭПД присвоила накладной УИД — он приходит в data.waybill.gis_uid
order.created
forwarding.created
документ появился у вас: отправителю — при создании (в том числе черновик из кабинета), получателю — когда документ до него дошёл (status: sent, stage: your_turn). Документ — в data.order / data.forwarding_document, той же формы, что карточка, с титулами в titles[]
order.status_changed
forwarding.status_changed
переход, который не является ответом на ваш же запрос: sent, accepted, declined, revoked, failed (последнее — только отправителю); прежний статус — в data.previous_status. По prepared и awaiting_operator событий нет — их вы получаете в ответе API
order.gis_uid_assigned
forwarding.gis_uid_assigned
ГИС ЭПД присвоила документу УИД — data.order.gis_uid / data.forwarding_document.gis_uid

У документа две стороны: отправитель и получатель получают одно событие (общий id), каждый в своём виде — direction, stage, available_actions считаются для вашей стороны. Песочница (operator: sandbox) до второй стороны не доходит. Подписка хранит список событий, зафиксированный при создании: созданная до появления order.* и forwarding.* их не получает — пересоздайте её с нужным списком или пустым («все события»).

POST /v1/webhooks
curl -sS https://api.etrn.app/v1/webhooks \
  -H "Authorization: Bearer $ETRN_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "url": "https://tms.example.ru/etrn/webhook",
    "events": ["waybill.created", "waybill.status_changed", "waybill.document_added", "waybill.gis_uid_assigned",
               "order.created", "order.status_changed", "forwarding.created", "forwarding.status_changed"]
  }'

# → 201: id, url, events, status, created_at и secret (больше нигде не отдаётся)

Проверьте обработчик, не дожидаясь рейса

POST /webhooks/{id}/ping отправляет на ваш адрес тестовое событие прямо сейчас и возвращает то, что ответил ваш сервер: код, время, причину неуспеха. Тело и подпись — как у боевых событий, поэтому проверяются и приём, и разбор, и сверка X-Etrn-Signature. Тип события — ping: он не входит в список подписки, так что заодно убедитесь, что незнакомые типы ваш код пропускает молча.

POST /v1/webhooks/{id}/ping
curl -sS -X POST https://api.etrn.app/v1/webhooks/$WEBHOOK_ID/ping \
  -H "Authorization: Bearer $ETRN_API_KEY"

# → 200 — мы позвали ваш адрес. Результат смотрите в полях:
{
  "delivered": false,
  "status_code": 502,
  "duration_ms": 88,
  "event_id": "7c1d2e3f-4a5b-6c7d-8e9f-0a1b2c3d4e5f",
  "error": "сервер ответил 502 — вебхук считается доставленным только на 2xx",
  "webhook_status": "active",
  "reactivated": false
}
  • HTTP 200 — это «мы позвали», а не «всё хорошо». Ошибка вашего сервера приходит в delivered и status_code: так видно, чей именно сервер отказал.
  • status_code: 0 — ответа не было вовсе: сеть, таймаут (ждём 8 секунд), TLS или адрес не прошёл проверку.
  • Пинг ничего не ломает. Попытки не растут, неудачный пинг не убивает подписку.
  • Оживляет сломанную подписку. Если status: broken — почините свой обработчик и вызовите ping: успешный ответ снимает broken (reactivated: true), и события идут дальше. Пересоздавать подписку и менять secret не нужно.

Правила доставки

  • At-least-once. Одно событие может прийти повторно — обработчик обязан быть идемпотентным по event.id.
  • Ретраи. Любой ответ не-2xx повторяется с растущей паузой до 24 часов. После суток неудач подписка помечается сломанной (status: broken) и видна такой в кабинете и в GET /webhooks. Вернуть в строй — ping (см. выше).
  • Порядок не гарантируется. Ориентируйтесь на created_at события и текущий status накладной, а не на порядок приходов.
  • Без вебхуков тоже можно. Поллинг GET /waybills?updated_from=… — законная стратегия для простых интеграций. Берите updated_from, а не created_from: накладная, поданная вчера, сегодня меняет статус, и в окно СОЗДАНИЯ такое изменение не попадёт. Запоминайте наибольший updated_at из прошлой выдачи и передавайте его в следующий запрос.

Проверка подписи #

Каждая доставка подписана заголовком
X-Etrn-Signature: t=<unix>,v1=<hex(hmac_sha256(secret, t + "." + body))>

Проверяйте подпись до разбора тела и отбрасывайте метки времени старше пяти минут — это защита от повторной отправки перехваченного запроса. Ниже — готовые функции.

"""Приём вебхука eTRN: проверка подписи и идемпотентная обработка."""
import hashlib, hmac, json, time

WEBHOOK_SECRET = "…"        # из ответа POST /webhooks — показывается один раз
REPLAY_WINDOW_SECONDS = 300  # 5 минут


def verify_signature(secret: str, header: str, body: bytes) -> bool:
    """X-Etrn-Signature: t=<unix>,v1=<hex(hmac_sha256(secret, t + "." + body))>."""
    try:
        parts = dict(p.strip().split("=", 1) for p in header.split(","))
        t, their = parts["t"], parts["v1"]
    except (ValueError, KeyError):
        return False
    if not t.isdigit() or abs(time.time() - int(t)) > REPLAY_WINDOW_SECONDS:
        return False   # метка времени вне окна — возможен replay
    ours = hmac.new(secret.encode(), (t + ".").encode() + body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(ours, their)   # сравнение за постоянное время


def handle_webhook(headers: dict, body: bytes) -> int:
    if not verify_signature(WEBHOOK_SECRET, headers.get("X-Etrn-Signature", ""), body):
        return 401
    event = json.loads(body)
    if is_processed(event["id"]):   # at-least-once: доставка может повториться
        return 200
    waybill = event["data"]["waybill"]
    if event["type"] == "waybill.status_changed":
        ...   # обновите рейс: waybill["external_ref"] → waybill["status"]
    mark_processed(event["id"])
    return 200   # любой 2xx = доставлено, иначе ретраи до 24 ч
const replayWindow = 5 * time.Minute // защита от replay

// verifyEtrnSignature проверяет заголовок
// X-Etrn-Signature: t=<unix>,v1=<hex(hmac_sha256(secret, t + "." + body))>.
func verifyEtrnSignature(secret, header string, body []byte) bool {
	var ts, v1 string
	for _, part := range strings.Split(header, ",") {
		if after, ok := strings.CutPrefix(part, "t="); ok {
			ts = after
		}
		if after, ok := strings.CutPrefix(part, "v1="); ok {
			v1 = after
		}
	}
	t, err := strconv.ParseInt(ts, 10, 64)
	if err != nil {
		return false
	}
	if d := time.Since(time.Unix(t, 0)); d > replayWindow || d < -replayWindow {
		return false // метка времени вне окна
	}
	mac := hmac.New(sha256.New, []byte(secret))
	mac.Write([]byte(ts))
	mac.Write([]byte("."))
	mac.Write(body)
	got, err := hex.DecodeString(v1)
	if err != nil {
		return false
	}
	return hmac.Equal(mac.Sum(nil), got) // сравнение за постоянное время
}

Тело доставки

Event · waybill.status_changed
{
  "id": "evt_2f8c1a5b9d",                      // ключ идемпотентности
  "type": "waybill.status_changed",
  "created_at": "2026-08-16T12:04:11Z",
  "data": {
    "previous_status": "pending_receipt",
    "waybill": {
      "id": "0b3c6c1e-8b7e-4f7a-9d2e-5a1f1c9e4b7d",
      "status": "in_transit",
      "external_ref": "TMS-2026-000123"
    }
  }
}

Экспедитору: поручения, расписки и заявки в кабинете #

Без API и без 1С: как экспедитор ведёт цепочку «поручение клиента → расписка → заявка перевозчику → накладная» в веб-кабинете cabinet.etrn.app. Названия разделов и кнопок — как на экране.

Что нужно заранее

Кабинет организации; подключённый оператор ЭДО с электронными перевозочными документами (раздел «Операторы ЭДО» — сейчас Астрал или СберКорус); электронная подпись на токене с КриптоПро-плагином в браузере — та же, которой подписывают «Новую ЭТрН»; если подписывает сотрудник, а не руководитель — МЧД.

1. Включите признак экспедитора

«Реквизиты» → блок «Организация — экспедитор»: поставьте галочку, укажите номер в реестре ГосЛог (он начинается с GL-) и дату включения, «Сохранить». Без признака клиенты не смогут адресовать вам поручения, а расписка упрётся в «нужен признак экспедитора» — это требование формата ФНС, оператор ЭДО его проверяет. Признак есть и у компании, и у ИП.

2. Примите поручение клиента

Раздел «Заявки» → вкладка «Ваш ход»: входящее поручение (ЭПЭ) лежит здесь. В карточке — стороны, договор, адреса приёма и выдачи, период отгрузки, груз. «Принять поручение» → подпись → документ уходит клиенту, стадия «Готово». Отказаться от поручения форматом не предусмотрено: если условия не подходят, клиент отзывает поручение сам (у него в карточке — «Отозвать поручение»).

3. Выпишите расписку

В карточке принятого поручения две кнопки. «Выписать расписку» — экспедиторская (ЭЭР): груз принят во владение для перевозки. «Складская расписка» — ЭСР: груз принят на хранение. Форма предзаполнена из поручения: стороны, договор, груз, УИД поручения. Допишите дату приёма, состояние груза и обязательства; у складской — ещё адрес склада, чей он (ваш или другого владельца), срок и время приёма, состояние тары, упаковки, маркировки и пломб, и кто принял груз, если это сотрудник — вместе с документом о его полномочиях. «Проверить и подписать» → подпись → расписка у клиента.

4. Закажите перевозку и оформите накладную

Там же, в карточке поручения: «Заявка перевозчику» — форма заказ-заявки (ЭЗЗ) с грузом и адресами из поручения; укажите перевозчика по ИНН, время подачи, требования к машине и цену. Перевозчик отвечает титулом Т2 — водитель, машина, стоимость; стадия заявки станет «Готово». В карточке согласованной заявки появится «Выписать ЭТрН»: «Новая ЭТрН» откроется с заполненными сторонами, водителем и машиной, вам — проверить и подписать. «Передать экспедитору» — субэкспедиция: поручение следующему экспедитору по тому же грузу.

5. Исправьте выданную расписку

Ошиблись в расписке, которая уже ушла клиенту? Откройте её карточку: когда расписка доставлена и получила УИД в ГИС ЭПД, там есть «Исправить расписку». По правилам формата ФНС доставленную расписку не правят, а заменяют: платформа создаёт черновик новой расписки с той же формой и запоминает, вместо какой она выписана, вы правите реквизиты и подписываете. Клиент получит новую расписку; в цепочке она помечена «взамен», прежняя остаётся в истории. Заменить можно только последнюю версию — у уже заменённой расписки кнопки нет, она появляется в карточке новой. Если расписку отклонила ГИС ЭПД уже после присвоения УИД, та же кнопка заведёт исправление с датой исправления — так предписывает формат.

6. Следите за ходом и документами

Вкладки «Ваш ход» · «Ждём ответа» · «Готово» · «Отклонено» · «Отозвано» — чей сейчас ход; фильтры «Входящие / Исходящие» и по виду («Заявки», «Поручения», «Расписки», «Складские»). В карточке любого документа — «Цепочка рейса»: поручение, расписки, заявки и накладные одним списком, из каждого звена открывается следующее. «Печатная форма» даёт лист для печати или PDF; рядом с каждым титулом — XML и файл подписи, это и есть юридически значимый документ. О каждом вашем ходе приходит письмо на почту организации. Списание — за каждый подписанный титул, включая ответные и исправления.

Перевозчику: ответ на заявку и накладная #

Заказ-заявка (ЭЗЗ) приходит от заказчика — грузоотправителя или экспедитора. Ответ на неё — титул Т2 с водителем, машиной и ценой; после этого заказчик выписывает накладную, а водитель подписывает свои титулы в приложении «Куб Транспорт».

Что нужно заранее

Кабинет перевозчика (у ИП — разделы «Мои рейсы» и «Заявки», у компании — «Заявки»); подключённый оператор ЭДО в «Операторы ЭДО»; подпись на токене с КриптоПро-плагином. Заявку можно получить только от заказчика, который видит вас у оператора ЭДО, — проверьте, что связка в «Операторах ЭДО» зелёная.

1. Входящая заявка

«Заявки» → вкладка «Ваш ход». В карточке: кто заказывает (грузоотправитель или экспедитор), откуда и куда везти, когда подать машину, груз с числом мест и массой, требования к транспорту, условия перевозки. Заказчик-ИП показан по ФИО — так устроен формат.

2. Примите или отклоните

«Принять заявку»: диспетчер с телефоном, водитель (ФИО, телефон, серия и номер водительского удостоверения с датой выдачи, ИНН), машина (госномер, тип, марка, грузоподъёмность, вместимость), при необходимости прицеп, стоимость без НДС и с НДС или порядок расчётов. Что не хватает, форма подсветит до подписи. «Отклонить» — тот же титул с причиной отказа; заказчик увидит её в карточке. Подпись — токеном в браузере. Ответ в приложении «Куб Транспорт» для ИП готовится; до его выхода отвечайте из кабинета.

3. Накладная по заявке

Когда заявка согласована, заказчик выписывает по ней ЭТрН: стороны, водитель и машина берутся из вашего ответа. Накладная появится в «Мои рейсы» (у ИП) или «Накладные», водитель подпишет приём груза (Т2) и выдачу (Т4) в приложении. Заявка и накладная связаны: из карточки одной открывается другая.

4. Если что-то пошло не так

«Оператор не принял» в карточке — причина оператора рядом, чаще всего это данные формы: исправьте и подпишите заново, отклонённый титул не списывается. Заявку, на которую вы уже ответили, заказчик переделать не может — он создаёт новую. Вопросы — в чат поддержки из кабинета, с номером документа.

Заказчику: поручение экспедитору и заявка перевозчику #

Грузовладелец либо поручает груз экспедитору, либо заказывает перевозку у перевозчика напрямую. Оба документа создаются в разделе «Заявки» кабинета грузоотправителя и подписываются той же подписью, что «Новая ЭТрН».

1. Поручение экспедитору

«Заявки» → «Поручение экспедитору». Форма по формату ФНС: вы (клиент) и экспедитор, договор транспортной экспедиции, грузоотправитель и грузополучатель, адреса приёма и выдачи груза со страной, период отгрузки, вид транспорта (тип кузова — только для автомобильного и железнодорожного), груз с маркировкой, массой нетто и брутто и страной происхождения. «Проверить и подписать» → подпись → стадия «Ждём ответа». Когда экспедитор примет поручение — «Готово»; его расписки (экспедиторская или складская) придут входящими документами в тот же раздел.

2. Отозвать поручение

Отказа от поручения у экспедитора нет, зато у вас есть отзыв: в карточке принятого поручения — «Отозвать поручение», укажите дату, причину и сумму фактических расходов экспедитора к возмещению, подпишите (титул Т3). Поручение станет «Отозвано».

3. Заявка перевозчику напрямую

«Заявки» → «Новая заявка»: перевозчик по ИНН (ИП — по ФИО), грузополучатель, адреса погрузки и выгрузки, время подачи машины, груз (места, масса брутто, объём, размеры места, вид тары, способ определения массы), требования к машине. «Подписать и отправить» → подпись → «Ждём ответа». Ответ перевозчика с водителем, машиной и ценой появится в карточке; стадия «Готово». Дальше — «Выписать ЭТрН»: черновик накладной уже заполнен из заявки и ответа.

4. Что приходит от экспедитора

Расписки — подтверждение, что груз принят: экспедиторская (во владение) или складская (на хранение, со складом и условиями). Действий по ним не нужно, они лежат входящими в списке и в «Цепочке рейса» вашего поручения. Исправленная расписка приходит новой версией с пометкой «исправление» — действует последняя. Если экспедитор везёт сам, накладную он выпишет от своего имени с признаком экспедитора, а вы будете указаны клиентом в разделе 1а.

Из 1С #

Большинство наших интеграций живут в 1С (УАТ, ERP, УТ, самописные конфигурации). Ничего специального не требуется: платформа умеет https и JSON из коробки, схема та же — POST /v1/waybills на подачу, вебхук или поллинг на статусы. Ниже — рабочие куски на встроенном языке и грабли, на которых спотыкаются почти все.

🩸
Первая и самая частая ошибка — BOM

ЗаписьJSON отдаёт строку, а привычное ПолучитьДвоичныеДанныеИзСтроки(Тело, КодировкаТекста.UTF8) дописывает в начало метку порядка байтов. Для JSON это мусор перед {, и запрос падает с bad-request ещё до разбора полей. Третий параметр ДобавлятьBOM = Ложь — обязателен.

Общий модуль: запрос к API
// Один вход для всех вызовов: заголовки, тело без BOM, разбор ответа.
Функция ВыполнитьЗапрос(Метод, Ресурс, Данные = Неопределено, КлючИдемпотентности = "") Экспорт

	Заголовки = Новый Соответствие;
	Заголовки.Вставить("Authorization", "Bearer " + КлючAPI());
	Заголовки.Вставить("Content-Type", "application/json; charset=utf-8");
	// Повтор с тем же ключом 24 часа безопасен: вернётся тот же ответ,
	// а не вторая накладная. Берите GUID документа — он у вас уже есть.
	Если ЗначениеЗаполнено(КлючИдемпотентности) Тогда
		Заголовки.Вставить("Idempotency-Key", КлючИдемпотентности);
	КонецЕсли;

	Соединение = Новый HTTPСоединение("api.etrn.app", 443, , , ,
		60, Новый ЗащищенноеСоединениеOpenSSL());
	Запрос = Новый HTTPЗапрос(Ресурс, Заголовки);

	Если Данные <> Неопределено Тогда
		Запись = Новый ЗаписьJSON;
		Запись.УстановитьСтроку(Новый ПараметрыЗаписиJSON(ПереносСтрокJSON.Нет));
		ЗаписатьJSON(Запись, Данные);
		ТелоСтрокой = Запись.Закрыть();
		// ↓ Ложь — это «не добавлять BOM». Без неё сервер вернёт 400.
		Запрос.УстановитьТелоИзДвоичныхДанных(
			ПолучитьДвоичныеДанныеИзСтроки(ТелоСтрокой, КодировкаТекста.UTF8, Ложь));
	КонецЕсли;

	Ответ = Соединение.ВызватьHTTPМетод(Метод, Запрос);

	Результат = Новый Структура("Код, Тело", Ответ.КодСостояния, Неопределено);
	ТелоОтвета = Ответ.ПолучитьТелоКакСтроку(КодировкаТекста.UTF8);
	Если ЗначениеЗаполнено(ТелоОтвета) Тогда
		Чтение = Новый ЧтениеJSON;
		Чтение.УстановитьСтроку(ТелоОтвета);
		Результат.Тело = ПрочитатьJSON(Чтение, Истина); // Истина = в Соответствие
		Чтение.Закрыть();
	КонецЕсли;

	Возврат Результат;

КонецФункции

Ошибки приходят в формате application/problem+json. Разбирать их удобно один раз в том же модуле: у любой ошибки есть title (что случилось), detail (что делать) и type — ссылка на страницу с описанием. У 422 дополнительно приходит errors с именами полей титула.

Подача накладной и разбор ошибок
Процедура ПодатьНакладную(ДокументСсылка) Экспорт

	Титул = СобратьТитул(ДокументСсылка); // ваша структура по схеме Waybill

	Ответ = ВыполнитьЗапрос("POST", "/v1/waybills", Титул,
		Строка(ДокументСсылка.УникальныйИдентификатор()));

	Если Ответ.Код = 201 Тогда
		ЗаписатьРезультат(ДокументСсылка, Ответ.Тело["id"], Ответ.Тело["status"]);
		Возврат;
	КонецЕсли;

	Если Ответ.Код = 422 Тогда
		// Титул не прошёл проверку: конкретные поля — в errors.
		Для Каждого Ошибка Из Ответ.Тело["errors"] Цикл
			Сообщить(Ошибка["field"] + ": " + Ошибка["message"]);
		КонецЦикла;
	ИначеЕсли Ответ.Код = 429 Тогда
		// Слишком часто. Сколько ждать — в заголовке Retry-After.
		ОтложитьПовтор(ДокументСсылка);
	ИначеЕсли Ответ.Код = 403 Тогда
		// Юрлицо-отправитель не принадлежит ключу: опечатка в ИНН или юрлицо не в группе.
		Сообщить(Ответ.Тело["detail"]);
	Иначе
		Сообщить(Ответ.Тело["title"] + ". " + Ответ.Тело["detail"]);
	КонецЕсли;

КонецПроцедуры

Приём вебхуков в HTTP-сервисе 1С

Публикуете HTTP-сервис на веб-сервере, его адрес указываете при создании подписки. Два правила, которые важнее остального: отвечайте сразу — записали событие в регистр и вернули 200, а разбирать будете регламентным заданием (мы ждём ответ 10 секунд, дольше — считается неудачей и уходит в повтор); и будьте идемпотентны по event.id — одно событие может прийти дважды, это нормальная работа доставки, а не сбой.

Модуль HTTP-сервиса: приём и проверка подписи
Функция СобытиеPOST(Запрос)

	ТелоДД = Запрос.ПолучитьТелоКакДвоичныеДанные();
	Подпись = Запрос.Заголовки.Получить("X-Etrn-Signature");

	Если Не ПодписьВерна(ТелоДД, Подпись) Тогда
		Возврат Новый HTTPСервисОтвет(401);
	КонецЕсли;

	Тело = ПолучитьСтрокуИзДвоичныхДанных(ТелоДД, КодировкаТекста.UTF8);
	Чтение = Новый ЧтениеJSON;
	Чтение.УстановитьСтроку(Тело);
	Событие = ПрочитатьJSON(Чтение, Истина);
	Чтение.Закрыть();

	// Тип ping приходит только по вашему запросу — это проверка связи.
	// Незнакомые типы просто пропускаем: их список со временем растёт.
	Если Событие["type"] <> "ping" Тогда
		// Только записать. Разбор — регламентным заданием: ответ должен
		// уйти за секунды, иначе доставка считается неудачной.
		РегистрыСведений.ЕТрН_ВходящиеСобытия.Записать(Событие); // по event.id, повтор не дублирует
	КонецЕсли;

	Возврат Новый HTTPСервисОтвет(200);

КонецФункции

// Подпись: t=<unix>,v1=<hex(hmac_sha256(secret, t + "." + тело))>
Функция ПодписьВерна(ТелоДД, ЗаголовокПодписи)

	Если Не ЗначениеЗаполнено(ЗаголовокПодписи) Тогда
		Возврат Ложь;
	КонецЕсли;

	Метка = ""; ПодписьHex = "";
	Для Каждого Часть Из СтрРазделить(ЗаголовокПодписи, ",") Цикл
		Если СтрНачинаетсяС(Часть, "t=") Тогда
			Метка = Сред(Часть, 3);
		ИначеЕсли СтрНачинаетсяС(Часть, "v1=") Тогда
			ПодписьHex = НРег(Сред(Часть, 4));
		КонецЕсли;
	КонецЦикла;

	// Защита от повтора чужой записи: метка старше 5 минут не принимается.
	Если Не ЗначениеЗаполнено(Метка)
		Или Макс(0, ТекущаяУниверсальнаяДатаВМиллисекундах() / 1000
			- Число(Метка)) > 300 Тогда
		Возврат Ложь;
	КонецЕсли;

	// Подписывается строка "<метка>." и СЫРОЕ тело — склеиваем в буфере,
	// чтобы не потерять байты на перекодировках.
	Префикс = ПолучитьДвоичныеДанныеИзСтроки(Метка + ".", КодировкаТекста.UTF8, Ложь);
	Данные = ОбъединитьДвоичныеДанные(Префикс, ТелоДД);

	Ключ = ПолучитьДвоичныеДанныеИзСтроки(СекретПодписки(), КодировкаТекста.UTF8, Ложь);
	// HMAC() — платформа 8.3.21 и старше. На младших — реализация через
	// ХешированиеДанных и БуферДвоичныхДанных (ipad/opad), см. документацию.
	Наша = НРег(ПолучитьHexСтрокуИзДвоичныхДанных(HMAC(Ключ, Данные, ХешФункция.SHA256)));

	Возврат Наша = ПодписьHex;

КонецФункции
📡
Проверьте сервис, не дожидаясь рейса

Опубликовали HTTP-сервис — вызовите POST /v1/webhooks/{id}/ping: придёт тестовое событие с настоящей подписью, а в ответе вы увидите, что ответил ваш веб-сервер. Самый быстрый способ поймать типовые «не туда опубликовали», «не пускает аутентификация IIS/Apache» и «сертификат не той цепочки» — до того, как поедет первая накладная.

Чек-лист перед боем

  • BOM выключен — третий параметр Ложь в ПолучитьДвоичныеДанныеИзСтроки.
  • Ключ хранится в безопасном месте — не в общем модуле и не в реквизите справочника, который открывают все. Ключ = право подписывать от лица организации.
  • Идемпотентность на подаче — Idempotency-Key с GUID документа: обрыв связи после отправки не создаст вторую накладную.
  • Таймаут соединения — 60 секунд, не по умолчанию: подача с большим титулом и медленным каналом иначе рвётся на ровном месте.
  • Веб-сервис отвечает быстро — запись в регистр и 200; вся логика — в регламентном задании.
  • Песочница сначала — тестовый ключ etrn_test_… и simulate прогонят накладную по всем статусам за минуты, включая вебхуки.

Несколько юрлиц #

Если вы возите под несколькими юрлицами, есть два пути. Оба рабочие — выбор зависит от того, как устроена ваша система.

ПутьКогда подходит
Ключ на каждое юрлицо Ваша система и так знает, от какого юрлица выписывает накладную, — она просто берёт нужный ключ. Ничего настраивать не нужно, работает сегодня.
Один ключ на группу Одна конфигурация на холдинг, один список накладных по всем юрлицам, одна подписка на вебхуки. Юрлицо-отправитель указываете в самом запросе.
⚠️
Что группа НЕ меняет

Число подключений к операторам ЭДО. Ящик оператор выдаёт конкретному юрлицу — это его абонент, его сертификат, его договор. Шесть юрлиц у четырёх операторов — это 24 подключения и с одним ключом, и с шестью. Группа экономит настройки интеграции, а не подключения.

Но заводить их теперь можно из одного кабинета — см. Подключение операторов за юрлица группы.

Как собрать группу

  1. В кабинете, раздел «Группа компаний», создайте группу и пригласите юрлица по ИНН.
  2. Каждое приглашённое юрлицо подтверждает участие в своём кабинете. До подтверждения ключ от его имени не работает — иначе любой, зная чужой ИНН, подавал бы документы за чужую компанию.
  3. В разделе «API» выпустите ключ на группу. Старые ключи продолжают работать как прежде.

Что меняется в запросах

  • Подача не меняется. ИНН грузоотправителя вы и так передаёте в parties с ролью shipper — платформа сама выберет ящик ЭДО этого юрлица.
  • Список отдаёт накладные всех юрлиц группы. ?org_inn= сужает до одного.
  • В каждой накладной и в каждом событии есть org_inn — чьё это юрлицо.
  • Одна подписка на вебхуки принимает события всех юрлиц.
  • Лимит запросов умножается на число юрлиц группы: ключ работает за несколько компаний.
🚫
Юрлицо вне группы — 403

Подача или чтение от имени ИНН, которого нет в группе (или он не подтвердил участие), отвечает 403 forbidden-org со списком доступных ИНН. Это не ошибка ключа — это состав группы: поправьте его в кабинете.

Подключение операторов за юрлица группы

Раздел кабинета «Операторы ЭДО» показывает все юрлица группы строками: разверните нужное — под ним откроются его операторы, его подключения и его кнопка «Подключить всех». Ходить по кабинетам каждого юрлица не нужно.

Право появляется в тот момент, когда юрлицо подтверждает участие в группе: подтверждение и есть его согласие, а выход из группы забирает право тем же действием. Каждое действие за другое юрлицо записывается, и его представителю уходит письмо. Сам участник видит список этих действий у себя в кабинете, в разделе «Группа компаний».

🔑
Часть операторов подключает только само юрлицо

Заявку по реквизитам (СберКорус) можно подать за участника группы. А вот Астралу нужен файл сертификата подписи этой организации, вход в СБИС идёт её ключом или паролем, а Контур — это вход в её существующий кабинет Контур.ID. Ключ лежит у юрлица, и предъявить его может только тот, у кого он есть: в таких карточках кабинет предлагает не «подключить», а позвать — участнику уходит письмо с просьбой сделать это у себя.

Проверить результат из интеграции можно там же, где мастер-данные: GET /v1/organization отдаёт по каждому юрлицу группы список операторов и статус подключения — не заходя в кабинет.

Мастер-данные: идентификаторы ЭДО #

Чтобы подать накладную, достаточно ИНН участников — платформа сама найдёт их ящики. Но если вы ведёте справочник контрагентов у себя, полезно один раз забрать идентификаторы ЭДО: по ним видно, у какого оператора заведён перевозчик и достижим ли он вообще, ещё до планирования рейса.

Схема: откуда интегратор берёт идентификаторы ЭДО и куда их подставляет
Полная цепочка: разовая настройка → сбор мастер-данных → подача → вебхуки

Шаг 1. Узнать себя — GET /v1/organization

Отдаёт юрлицо ключа, тариф с остатком лимита и список операторов ЭДО. У каждого оператора — статус подключения и participant_id: идентификатор, под которым вы заведены у этого оператора (BoxId у Диадока, ИдАбонента у Астрала, числовой у СБИС). У оператора со статусом pending или error идентификатора нет — он появляется только вместе с подключением.

Если ключ выпущен на группу компаний, в ответе дополнительно приходят group и orgs[] — все подтвердившие юрлица, у каждого свой список операторов. Ключ, выпущенный на одно юрлицо, вернёт только его: это не ошибка, а область действия ключа — перевыпустите его в кабинете с областью «Вся группа».

GET /v1/organization
{
  "inn": "7707083893",
  "name": "ООО «Ромашка»",
  "group": { "id": "grp-1" },
  "orgs": [
    { "inn": "7707083893", "name": "ООО «Ромашка»",
      "operators": [
        { "code": "kontur", "status": "connected",
          "participant_id": "ddb51104-ea2a-4c9d-9b55-1c90394ab932" },
        { "code": "sbis", "status": "pending" }
      ] },
    { "inn": "7713488175", "name": "ООО «Ромашка-Юг»", "operators": [ … ] }
  ]
}

Шаг 2. Собрать справочник перевозчиков — GET /v1/counterparties/{ИНН}

Делается один раз на контрагента и кладётся в ваши мастер-данные. Ответ говорит три вещи: достижим ли он в ЭДО вообще (reachable), у какого оператора заведён (operators[], флаг roaming — «у чужого оператора, дойдёт роумингом») и под каким идентификатором — participant_id.

Здесь participant_id — это ИдУчастникаЭДО из реестра ФНС вида 2BM-ИНН-КПП-серийник. Первые три знака сами называют оператора, так что спрашивать отдельно не нужно:

ПрефиксОператор
2BMКонтур.Диадок
2AEАстрал
2BEСБИС
2BKСберКорус
GET /v1/counterparties/7713488175
{
  "inn": "7713488175",
  "reachable": true,
  "name": "ООО «Дельта»",
  "participant_id": "2BM-7713488175-771301001-201901231200",
  "operators": [ { "code": "kontur", "roaming": false } ]
}

Свой собственный ИдУчастникаЭДО берётся этой же ручкой — по своему ИНН, ограничения «только чужие» нет.

Шаг 3. Подача — и надо ли вообще подставлять идентификатор

Не надо. В parties[] обязательны только role и inn — по ним платформа сама находит ящик получателя: сначала у своего оператора по ИНН и КПП, а если контрагент у чужого — заводит роуминговый ящик-шлюз. Приглашений и ручных заявок для этого не нужно.

Поле operator_subscriber_id существует для случая «мы и так знаем ид абонента»: оно экономит один-два запроса к оператору на каждой подаче.

⚠️
participant_id и operator_subscriber_id — разные вещи

У Астрала, СБИС и СберКоруса ид абонента и есть ИдУчастникаЭДО (2AE, 2BE, 2BK) — там подстановка сработает. А Контур.Диадок адресует ящик своим идентификатором (GUID вида ddb51104-ea2a-…), и ИдУчастникаЭДО 2BM-… для него не BoxId.

Платформа это распознаёт: если в operator_subscriber_id пришёл ид участника ФНС, он трактуется как «ищи по реквизитам», и накладная всё равно доезжает. Но полагаться на это не стоит — либо кладите ид абонента того же оператора, либо не кладите ничего и оставьте поиск платформе.

Шаг 4. Дальше — вебхуки

Статусы, входящие титулы и подписи приходят на ваш URL сами — опрашивать список не нужно. См. Вебхуки.

Частые вопросы интеграторов #

Всё ниже — из настоящих интеграций последних недель. Если вашего вопроса нет, напишите нам: ответ появится здесь.

Получили 504 на подаче. Накладная создана или нет?
Не знаете — и не должны. Через пару минут повторите тот же запрос с тем же титулом: завершённая подача вернёт 201 с той же накладной, не принятая оператором — уйдёт заново, а пока исход неизвестен, повтор получит 409 с reason: submission_pending. Дубля не будет ни в одном случае. См. таймауты.
Получили 424 operator-reauth-required. Что делать?
Ничего в коде. Оператор перестал принимать доступ клиента (обычно после смены пароля или входа с другого устройства). Клиенту нужно войти к оператору заново из кабинета «Куб Транспорт»: Операторы ЭДО → оператор → Подключить. Платформа отправит ему письмо с этой инструкцией сама. Повторяйте подачу после входа.
Почему 403 forbidden-org, если ключ рабочий?
Ключ принадлежит организации, а в parties[role=shipper] стоит ИНН другого юрлица. Либо это опечатка, либо юрлицо нужно добавить в группу компаний и выпустить групповой ключ. В ответе перечислены ИНН, доступные ключу.
Сколько ждать Т2 от водителя и как о нём узнать?
Столько, сколько водитель едет до погрузки: Т2 он подписывает в приложении при приёме груза. Подпишитесь на waybill.document_added и waybill.status_changed — или читайте GET /waybills?updated_from= раз в несколько минут.
В ответе на создание gis_uid пустой. Это ошибка?
Нет. УИД ГИС ЭПД присваивает государство после регистрации титула, обычно в течение минут. Он придёт событием waybill.gis_uid_assigned и появится в карточке. Подавайте следующие накладные, не дожидаясь.
Подпись у нас валидная, а платформа отвечает «контейнер не разобран».
Чаще всего в поле лежит не отсоединённая CMS: PEM с заголовками -----BEGIN, сертификат вместо подписи или присоединённая подпись с документом внутри. BER и двойной base64 мы принимаем сами. Прогоните файл через POST /waybills/validate: ответ покажет поле и причину.
Астрал отклонил накладную: «пропущено обязательное свойство Получатели».
У Астрала получатели адресуются абонентскими идентификаторами 2AE…. Платформа берёт их из ИдФайл титула; если титул сформирован без идентификаторов участников, передайте их явно в parties[].operator_subscriber_id.
Подача отвечает 409 с reason: submission_pending. Что делать?
Запрос с этим титулом уже ушёл оператору ЭДО, а ответа нет: он не пришёл, оборвался или не разбирается, пришла ошибка 500/502/504 или оператор не уложился в срок ответа. Так же отвечает повтор, пока первая подача ещё в пути. Исход неизвестен — документ мог появиться у оператора, поэтому платформа держит бронь на титул и не отправит его повторно. Не повторяйте подачу в цикле и не пересоздавайте титул с новым ИдФайл — так у оператора может появиться второй документ. Подачи в Контур платформа сверяет сама: раз в 10 минут ищет титул по ИдФайл и байтам в ящике, через который подача ушла; если просмотреть ящик полностью не удаётся, блок остаётся. Нашла — накладная появляется, приходит вебхук waybill.created, а повтор этого титула (в том числе подписанного заново) возвращает её. Убедилась, что документа у оператора нет, — снимает блок, и повтор уходит оператору заново, но не раньше чем через час после неудачной подачи и только когда ящик просмотрен полностью и синхронизирован уже после этого часа. Обычно всё решается в пределах часа после восстановления связи с оператором; для других операторов срок не обещаем. Если через пару часов ничего не изменилось, пришлите request_id в поддержку. После 503 operator-throttled и 424 всё иначе: там титул к оператору не ушёл, и повтор того же титула уходит заново.
Оператор отклонил титул, причину мы устранили. Почему повтор отвечает 409?
Платформа не отправляет оператору повторно ту же подписанную версию, которую он уже отклонил: 409 conflict «эта версия уже отклонена оператором». Подпишите титул заново и подайте с новым Idempotency-Key. Новая подпись — новая отправка, второго документа у оператора не будет, потому что первый он не принял.
«Контур.Диадок не принял документ: у плательщика закончился оплаченный пакет документов».
Подачу у Контура оплачивает сторона титула из своего пакета документов: Т1 — грузоотправитель, Т2 — перевозчик. В detail указан ИНН плательщика. Пополните баланс у Контура и подайте титул новой подписью.
Контур: «Подписание доступно только участнику погрузки».
Титул подан от ящика, которого нет среди участников накладной. Проверьте, что ИНН и идентификатор отправителя в титуле — это ящик клиента, авторизованный в кабинете, а не другое юрлицо группы.
У перевозчика нет ЭДО. Накладная дойдёт?
Нет: Т2 подписать некому. GET /counterparties/{inn} покажет это до подачи. Перевозчика можно пригласить в платформу из кабинета — подключение и выпуск подписи он проходит сам, в приложении.
Одна подписка на вебхуки получает и песочницу, и бой. Как отличать?
По полю operator в событии: у песочницы "sandbox", у боя код реального оператора. Либо заведите отдельный URL на время отладки и удалите подписку после.
Как связать накладную с рейсом в нашей ТМС?
Передайте свой номер в external_ref при подаче: он возвращается в карточке, в списке (фильтр ?external_ref=) и в каждом событии. Идентификатор на нашей стороне хранить не обязательно.
Можно ли отозвать или исправить поданную накладную через API?
В v1 нет: аннулирование и исправления — это отдельные титулы, которые стороны подписывают у оператора, и они приходят к вам в цепочке документов. Исправленный титул Т1 подаётся как новая накладная с новым ИдФайл.
Мы холдинг из шести юрлиц. Шесть ключей или один?
Работают оба варианта. Один групповой ключ даёт единый список, единую подписку на события и лимит, умноженный на число юрлиц. Подключений к операторам это не уменьшает: ящик выдаётся каждому юрлицу. См. Несколько юрлиц.
Что вы делаете, когда у вас что-то ломается?
Любая серверная ошибка в этом API поднимает дежурного немедленно, отказы операторов классифицируются и не превращаются в 500. В ответе всегда есть request_id — с ним мы находим запрос вместе с ответом оператора за минуты.

Чек-лист перед боем #

Двенадцать пунктов, после которых интеграции наших клиентов не ломались. Пройдите их на тестовом ключе — код тот же, меняется только ключ.

  1. Ключ хранится в секрет-хранилище, в логах и репозитории его нет; у ключа понятное имя.
  2. GET /organization отвечает 200 и показывает ожидаемые ИНН.
  3. Титул проходит POST /waybills/validate без ошибок — этот вызов стоит в CI титулогенератора.
  4. Подача идёт с Idempotency-Key, а external_ref несёт номер рейса.
  5. Клиент повторяет запрос при 504 и обрыве соединения, а не помечает рейс ошибкой.
  6. 409 с reason: submission_pending не повторяется в цикле и не превращается в новый титул: detail показывается человеку.
  7. 424 показывается человеку с текстом detail, а не уходит в бесконечный ретрай.
  8. 422 разбирается по errors[], поле и текст попадают в интерфейс оператора ТМС.
  9. 429 и 503 operator-throttled обрабатываются по Retry-After; массовые подачи идут не быстрее 10 в секунду.
  10. Вебхук проверяет X-Etrn-Signature, отбрасывает метки старше пяти минут и идемпотентен по event.id.
  11. Обработчик отвечает 2xx до тяжёлой работы и укладывается в 10 секунд.
  12. Незнакомые типы событий и новые поля пропускаются молча.
  13. В песочнице пройден полный цикл: подача → simulate до delivered → все события получены → документы цепочки скачаны.

Все ручки #

Полный контракт с телами запросов и схемами ответов — в OpenAPI-спеке.

📍
Пути указаны относительно базового URL

Полный адрес — https://api.etrn.app/v1 + путь из таблицы. Например GET /counterparties/{inn} вызывается как https://api.etrn.app/v1/counterparties/7731162754. Забыли префикс — ответ 404 подскажет правильный адрес.

РучкаНазначениеСкоуп
POST /waybillsподать ЭТрН (титул Т1)waybills:write
POST /waybills/validateпроверить титул без подачи — ничего не списываетсяwaybills:write
GET /waybillsсписок: фильтры status, number, external_ref, created_from, created_to, updated_from, updated_to; курсорwaybills:read
GET /waybills/{id}карточка накладной с цепочкой documents[]waybills:read
GET /waybills/{id}/documents/{docId}/contentXML титула или подпись: ?part=xml|sigwaybills:read
GET /waybills/{id}/qrQR подтверждения перевозки (PNG)waybills:read
POST /waybills/{id}/simulateтолько песочница: продвинуть цепочкуwaybills:write
POST /webhooksсоздать подписку (в ответе secret — один раз)webhooks:manage
GET /webhooksсписок подписок со статусом active | brokenwebhooks:manage
DELETE /webhooks/{id}удалить подпискуwebhooks:manage
POST /webhooks/{id}/pingтестовое событие на ваш адрес сейчас; в ответе — код вашего сервераwebhooks:manage
GET /organizationорганизация ключа, тариф, остаток лимиталюбой
GET /operatorsсправочник операторов ЭДО и их доступностьwaybills:read
GET /counterparties/{inn}достижим ли контрагент — напрямую или роумингомwaybills:read
Заявки и экспедиторские документы. {family} — orders (заявка перевозчику), forwarding-orders (поручение экспедитору), forwarding-receipts (экспедиторская и складская расписки, kind расписка). Скоуп orders:* для заявок, forwarding:* для экспедиции; ключи с waybills:* проходят.
POST /{family}создать документ: черновик + титул Т1 на подпись (title.xml, sha256, prepared_title_id); 422 с document_id, 409 с existing_id…:write
GET /{family}список: direction, stage, status, counterparty_inn, external_ref, number, date_from, date_to, org_inn; курсор…:read
GET /{family}/{id}карточка: form, card, titles[], problems[], available_actions[], links…:read
PATCH /{family}/{id}поправить черновик: объекты сливаются, массивы заменяются…:write
DELETE /{family}/{id}удалить черновик…:write
POST /{family}/{id}/sendсобрать титул Т1 для существующего черновика (после PATCH или «на основании»)…:write
GET /{family}/{id}/titles/{code}байты собранного титула t1 | t2 | t3 с sha256…:read
POST /{family}/{id}/titles/{code}/submitподать подписанный титул оператору ЭДО…:write
GET /{family}/{id}/documentsфайлы титулов документа…:read
GET /{family}/{id}/documents/{docId}/contentXML титула или подпись: ?part=xml|sig…:read
POST /orders/{id}/accept · /declineответ перевозчика — титул Т2 на подпись; у отказа обязателен reasonorders:write
POST /orders/{id}/link-waybillсвязать заявку с накладной (waybill_id)orders:write
POST /orders/{id}/waybill-formчерновик ЭТрН по принятой заявке: груз, стороны, водитель и транспорт перенесеныorders:write
POST /orders/{id}/simulate · /forwarding-orders/{id}/simulate[тест-контур] ответ контрагента: accepted | declined — настоящий Т2, УИД, статус и вебхуки как в бою
POST /forwarding-orders/{id}/accept · /revokeсогласие экспедитора (Т2) · отзыв поручения клиентом (Т3)forwarding:write
POST /forwarding-receipts/{id}/correctисправление выданной расписки (ЭЭР или ЭСР): черновик той же расписки с блоком correction; дальше PATCH (дата) и /sendforwarding:write
POST /forwarding-orders/{id}/receipt · /order · /forwardдокумент «на основании» поручения: расписка клиенту ({"kind":"warehouse_receipt"} — складская), заявка перевозчику, поручение следующему экспедиторуforwarding:write
GET /chainцепочка документов и накладных по одному звену: order_id | forwarding_order_id | forwarding_receipt_id | waybill_idлюбой …:read

Ошибки #

Все ошибки — в формате RFC 9457 Problem Details (application/problem+json): type — стабильный URI-классификатор для машинной обработки, title и detail — человеку, request_id — приложите к обращению в поддержку, errors[] — пополевые детали при 422. HTTP-статус в теле не дублируется: источник один — статус ответа. Серверных 500 в этом API быть не должно: любая такая ошибка поднимает дежурного.

424 Failed Dependency
{
  "type": "https://api.etrn.app/errors/operator-reauth-required",
  "title": "Оператор ЭДО ждёт повторного входа клиента",
  "detail": "Контур отверг доступ организации ИНН 5038129757: клиенту нужно заново войти в Контур из кабинета (Операторы ЭДО → Контур → Подключить). Повторяйте подачу после входа.",
  "request_id": "req_c81e728d"
}

Внутри одного type машинную причину может уточнять поле reason — расширение RFC 9457. У накладных оно есть у 409 conflict при подаче; у заявок и экспедиции — у каждого своего классификатора и повторяет машинный код причины платформы. Незнакомые значения трактуйте как обычный conflict.

409 Conflict — исход подачи неизвестен
{
  "type": "https://api.etrn.app/errors/conflict",
  "title": "По этому титулу уже есть подача, её исход сверяется",
  "detail": "Отправка ушла оператору ЭДО, но ответ от него не пришёл. Повтор заблокирован, чтобы у оператора не появился дубль. Если за час ничего не изменится, напишите в поддержку — проверим у оператора",
  "request_id": "req_3c59dc04",
  "reason": "submission_pending"
}
КлассификаторHTTPКогдаПовторять?Что делать
bad-request400тело не JSON, лишний параметр, неверный формат полянетисправьте запрос по detail
unauthorized401ключ не передан, неверен или отозваннетпроверьте заголовок Authorization и статус ключа в кабинете
insufficient-scope403у ключа нет нужного скоупанетвыпустите ключ со скоупом waybills:write / webhooks:manage (заявки и экспедиция — orders:write / forwarding:write)
forbidden-org403ИНН отправителя не принадлежит ключунетсверьте parties[role=shipper].inn со списком юрлиц ключа; поправьте состав группы в кабинете
sandbox-only403ручка песочницы вызвана боевым ключомнетsimulate есть только у etrn_test_-ключей
not-found404нет такой накладной, документа или подписки — либо она чужаянетпроверьте id и организацию ключа; забытый префикс /v1 тоже даёт 404 с подсказкой
idempotency-conflict409тот же Idempotency-Key с другим теломнетновый запрос — новый ключ
conflict409действие невозможно в текущем состоянии (например, simulate назад по циклу). При подаче причину называет поле reason: submission_pending — по этому титулу уже есть подача, запрос ушёл оператору, а ответа нет (исход неизвестен); submission_rejected — эта версия уже отклонена операторомнетsubmission_pending — не пересоздавайте титул: подачи в Контур платформа сверяет сама, через пару часов без изменений — request_id в поддержку; submission_rejected — новая подпись; без reason — прочитайте карточку и повторите с учётом состояния
validation-failed422титул, подпись или стороны не прошли проверку — либо оператор ЭДО отклонил документ по существу; у заявок и экспедиции — поля формы в snake_case, при создании черновик сохранён и его id — в document_id (title «Оператор ЭДО отклонил документ», причина и действие в detail)после исправлениядетали в errors[]; отладка — POST /waybills/validate; см. типичные причины
external-ref-taken409документ с таким external_ref уже есть у этого юрлица (заявки и экспедиция)нетработайте с существующим — его id в existing_id
title-changed409форма менялась после сборки титула: подпись поставлена на старые байтыпосле пересборкизаберите титул заново (…/send, …/accept) и подпишите новые байты
awaiting-operator409документ уже ушёл оператору ЭДО, исход ещё не ясеннетследите за status в карточке; повтор подачи не нужен
action-not-allowed409действие не подходит к статусу или стороне документа: нет собранного титула, на документ уже отвеченонетсверьтесь с available_actions[] карточки
order-number-taken409номер и дата документа уже поданы этим отправителемнетвозьмите другой номер или найдите поданный документ в списке
signer-mismatch422сертификат подписи принадлежит не стороне титулапосле исправленияподписывайте сертификатом юрлица-стороны либо по МЧД с signer.poa
sign-grant-required403у юрлица ключа нет подтверждённой связки ЭДО, дающей право подписи за эту сторонупосле подключенияподключите этому юрлицу оператора ЭДО в кабинете
forwarder-required409у организации нет признака экспедиторапосле включенияпризнак ставится в профиле организации в кабинете
decline-unsupported · gis-uid-pending · operator-required · doc-type-unsupported · roaming-unavailable409отказа от поручения нет в формате ФНС — отзывайте; у поручения ещё нет УИД ГИС ЭПД; нет подключённого оператора для отправки; оператор не поддерживает этот вид документа; к оператору контрагента документ роумингом не ходитпо ситуацииdetail говорит, что сделать; оператора можно сменить полем operator
operator-reauth-required424оператор отверг доступ клиента: нужен повторный входпосле входа клиентапокажите detail клиенту; письмо с инструкцией он получит от платформы
operator-access-revoked424у платформы отозваны права на организацию у операторапосле выдачи правклиент выдаёт права в кабинете оператора (текст в detail)
operator-api-not-enabled424оператор не включил клиенту обмен по APIпосле включенияклиент обращается в поддержку оператора; повтор после
rate-limited429превышен лимит запросов ключачерез Retry-Afterследите за X-RateLimit-Remaining
upstream-unavailable502часть платформы не ответилада, с паузойповторите через несколько секунд; если держится — request_id в поддержку
operator-throttled503оператор ЭДО недоступен или перегружен. На подаче: документа у оператора нет — запрос не дошёл или оператор явно отказал; бывает и на чтении документов цепочки и QRчерез Retry-After (сейчас 120 с)повторите тот же титул — он уйдёт оператору заново
operator-disabled503оператор отключён платформой (обслуживание, авария); причина — в detailнет, Retry-After не приходитне передавайте operator или выберите другой из GET /operators
counterparty-check-unavailable503справочник оператора для проверки контрагента недоступенда, позжеподавайте без проверки: достижимость проверится при подаче
upstream-timeout504платформа не ответила за 30 секунд (редко: на подачу она отвечает не позже 25); подача могла начаться, начатая продолжается в фонеда, тем же документом через пару минутзавершённая подача вернёт ту же накладную, не принятая — уйдёт заново, пока исход неизвестен — 409 submission_pending; см. таймауты
internal500авария платформыда, с паузойдежурный уже уведомлён; приложите request_id

Отдельный случай при подаче: явно переданный неизвестный или выключенный код operator отклоняется как 422 ещё на валидации запроса — до похода к оператору.

Лимиты #

ЛимитЗначение
Частота запросов На ключ: 30 запросов можно послать сразу, дальше ёмкость пополняется со скоростью 10 в секунду.
X-RateLimit-Limit — ёмкость (30), X-RateLimit-Remaining — сколько от неё осталось, X-RateLimit-Reset — unix-время полного восстановления, X-RateLimit-Policy — политика целиком.
При превышении — 429 и Retry-After в секундах: столько ждать до следующего запроса (это меньше, чем до полного восстановления).
Размер запроса подачититул до 2 МБ, всё тело запроса до 8 МБ
Ожидание оператора при подачеответ на подачу — не позже 25 секунд с начала её обработки, включая оформление лицензии оператора. Отправка к сроку не началась — 503 operator-throttled, титул не уходил; не ответил оператор — 409 conflict с reason: submission_pending, подача продолжается в фоне до 90 секунд, её итог получит повтор тем же титулом
Вебхуки10 секунд на ответ вашего сервера; 8 попыток с паузами от 1 минуты до 12 часов; ping ждёт 8 секунд
ИдемпотентностьIdempotency-Key до 128 символов, окно 24 ч. Повтор с тем же ключом — исходный результат (200), тот же ключ с другим телом — 409
Пагинацияlimit до 200 (по умолчанию 50), курсор next_cursor; пустой курсор — страниц больше нет
Инкрементальное чтениеupdated_from / updated_to — окно ПОСЛЕДНИХ ИЗМЕНЕНИЙ (RFC 3339). Накладная, которую с момента подачи не меняли, попадает в окно по времени создания, поэтому свежие подачи не теряются. Окно действует на каждой странице: next_cursor запоминает его сам, повторять при переходе не нужно. Повторить можно — значение из запроса главнее, так что окно допустимо сузить на ходу. Остальные фильтры курсор не запоминает
ВремяВсе created_at / updated_at — в UTC с суффиксом Z: 09:30:00Z — это 12:30 по Москве. Сравнивайте моменты, а не циферблат. Своё смещение в параметрах допустимо (…T12:00:00+03:00), но + в строке запроса кодируйте как %2B — иначе он станет пробелом и придёт 400
Цикл опросаКурсор — не водяной знак, между циклами его не хранят. Каждый цикл: запрос без cursor с updated_from = наибольший updated_at прошлого цикла минус минута запаса, дальше по next_cursor, пока он не опустеет

Что изменилось #

Коротко о том, что менялось в API и платформе за ним. Всё внутри v1 аддитивно: старый код продолжает работать.

ДатаЧто
14 октября 2026Расписки по правилам обмена: менять их по своей инициативе нельзя, исправляют только по запросу ГИС ЭПД. POST /forwarding-receipts/{id}/correct исправляет расписку, отклонённую ГИС после присвоения УИД (блок correction, связь corrects); для доставленной или принятой отвечает 409 receipt-replacement-off. Входящие замены от контрагентов приходят как раньше, со связью replaces. См. раздел.
12 октября 2026, вечерСкладская расписка (ЭСР) в ресурсе /forwarding-receipts: kind: warehouse_receipt, форма со складом, владельцем и условиями хранения; POST /forwarding-orders/{id}/receipt принимает kind, список — фильтр ?kind=. Исправление расписок обоих видов — POST /forwarding-receipts/{id}/correct, блок correction в форме, связь corrects в GET /chain.
12 октября 2026Заявки и экспедиторские документы в API: /orders (заявка перевозчику, ЭЗЗ), /forwarding-orders (поручение экспедитору, ЭПЭ), /forwarding-receipts (экспедиторская расписка, ЭЭР) и /chain. Титул собирает платформа из JSON-формы, вы подписываете байты XML и подаёте подпись отдельным вызовом; ответ контрагента — accept / decline / revoke; документы «на основании» поручения; связка заявки с накладной и форма ЭТрН по заявке. Новые классификаторы ошибок с reason, document_id у 422 при создании, existing_id у 409 external-ref-taken. Скоупы orders:* и forwarding:*; ключи с waybills:* работают без перевыпуска. См. раздел.
25 сентября 2026503 operator-throttled приходит с заголовком Retry-After (120 секунд), а его title теперь «Оператор ЭДО недоступен»: этот классификатор означает не только перегрузку, но и недоступность оператора. У operator-disabled Retry-After нет — срок знает администратор. У 409 conflict при подаче появилось поле reason: submission_pending (исход прошлой подачи неизвестен, повтор заблокирован) или submission_rejected (версия уже отклонена оператором). Ответ на подачу — не позже 25 секунд с начала обработки, включая оформление лицензии: не начатая к сроку отправка не уходит вовсе (503 operator-throttled), а не ответивший к сроку оператор — 409 submission_pending, а не 504. 503 на подаче теперь всегда значит «документа у оператора нет»; всё неоднозначное, включая оборванный ответ, — 409 submission_pending. Чтение документов цепочки и QR при недоступном операторе — 503 operator-throttled вместо 404. Неизвестный исход подачи в Контур платформа сверяет сама: документ нашёлся — накладная появляется с вебхуком waybill.created, его нет — блок снимается, но не раньше чем через час. После 503 operator-throttled и 424 повтор того же титула уходит оператору заново, а завершённая подача отдаёт свою накладную без второй отправки и точному повтору запроса, и повтору, подписанному заново. Слаги и type не менялись.
22 сентября 2026В GET /counterparties/{inn} добавлены participant_ids (все адреса контрагента без дублей) и поля оператора в operators: prefix, name, participant_id, integrated. Распознаётся весь действующий реестр операторов ФНС, а не четыре интегрированных: раньше идентификатор вроде 2AL-… (Такском) давал пустой operators. Уточнена формулировка reachable: это «найдена в реестре ЭДО», а не гарантия доставки.
8 сентября 2026Курсор списка накладных запоминает окно изменений: продолжение выдачи по next_cursor остаётся тем же срезом, даже если updated_from не повторить. Раньше на второй странице окно терялось и возвращались давно не менявшиеся накладные. Ранее выданные курсоры остаются рабочими; остальные фильтры по-прежнему передаются на каждой странице.
3 сентября 2026Повтор подачи после таймаута больше не создаёт второй документ у оператора: подача бронирует титул до отправки и продолжается в фоне; повтор дожидается результата. Письма клиенту о потере и восстановлении связи с оператором.
2 сентября 2026Отказы операторов типизированы: 424 operator-reauth-required / operator-access-revoked / operator-api-not-enabled вместо 500. Ограничены бюджеты на получение документооборота и QR, каждая 5xx логируется с request_id.
30 августа 2026Групповые ключи для холдингов: подача от любого юрлица группы, единый список с ?org_inn=, одна подписка на события. 403 forbidden-org со списком доступных ИНН. Титул разбирается при подаче: мусор отвечает 422, а не уходит оператору.
28 августа 2026participant_id везде означает идентификатор участника ЭДО по ФНС; добавлен all_participant_ids. В GET /organization — список юрлиц группы с их операторами. Оплата Т1-API разовая: 402 из канала убран.
16 августа 2026Запуск v1: подача Т1, карточка и цепочка документов Т1–Т9, QR, вебхуки с HMAC-подписью и ping, песочница по тестовому ключу с simulate, этот портал.

Поддержка #

Пишите на support@etrn.app и прикладывайте request_id из тела ошибки — по нему запрос находится целиком, вместе с ответом оператора ЭДО. Если ошибки нет, а поведение странное, приложите время запроса с точностью до минуты и префикс ключа (etrn_live_a1b2c3d4e…) — полное значение ключа присылать не нужно никогда.