Как подключиться к Payment Agent
Один сайт со всеми гайдами: быстрый старт, оба сценария оплаты, вебхуки с примерами, статусы, ошибки и чек-лист перед продом.
Раздел 00
Обзор
Платежный агент (ПА) принимает рубли от клиента консьерж-сервиса и рассчитывается
с зарубежным партнёром в его валюте. Эта документация описывает, как подключиться
к API — со стороны ЦСО и со стороны партнёра.
Единый сайт для клиента: откройте index.html в браузере —
все разделы в одном файле с меню слева. Пересобрать после правок markdown:
npm run docs:partner
Что делает ПА
Разделы
| Документ | О чём |
|---|---|
| Быстрый старт | Первое поручение за 10 минут, только curl |
| Аутентификация | OAuth 2.0, API-ключи, идемпотентность |
| Сценарий 8.1: инвойс | Партнёр выставил инвойс, оплата по SWIFT |
| Сценарий 8.2: ссылка партнёра | Оплата на платёжной форме партнёра |
| Вебхуки | Приём и проверка подписи, примеры на 3 языках |
| Статусы поручения | Статусная модель и допустимые переходы |
| Ошибки | Формат ответа и справочник кодов |
| Sandbox | Отладка без реального банка и партнёра |
| Чек-лист перед продом | Что проверить перед боевым запуском |
Интерактивная документация
При запущенном сервисе доступны:
http://localhost:3000/docs— справочник Scalar с примерами и «попробовать»http://localhost:3000/v1/openapi.json— спецификация OpenAPI 3.0http://localhost:3000/v1/swagger— Swagger UI
Кто с кем взаимодействует
| Направление | Транспорт | Аутентификация |
|---|---|---|
| ЦСО → ПА | REST, /v1/orders/* | Bearer-токен или X-API-Key |
| ПА → ЦСО | Вебхуки на callback_url | Подпись HMAC-SHA256 в заголовке |
| Партнёр → ПА | REST, /v1/callbacks/partner/* | Подпись HMAC-SHA256 в заголовке |
| ПА → Партнёр | Вебхуки на адрес из онбординга | Подпись HMAC-SHA256 в заголовке |
| Банк → ПА | REST, /v1/callbacks/bank/statement | Подпись HMAC-SHA256 в заголовке |
Поддержка
Вопросы по интеграции: integration@meafcoop.com.
При обращении приложите pa_order_id и request_id из тела ошибки.
Раздел 01
Быстрый старт
Проведём поручение от создания до закрытия за пять запросов. Нужен только curl.
Всё выполняется в sandbox: реальные деньги не двигаются, SWIFT не отправляется.
0. Поднять сервис
npm install
npm run start:dev
Проверка:
curl http://localhost:3000/v1/health
{
"status": "ok",
"environment": "development",
"storage": "in-memory",
"sandbox_enabled": true,
"timestamp": "2026-07-28T10:30:00.000Z"
}
1. Получить токен
curl -X POST http://localhost:3000/v1/oauth/token \
-H 'Content-Type: application/json' \
-d '{
"grant_type": "client_credentials",
"client_id": "cso-sandbox",
"client_secret": "sk_test_51H9xQ2LkNvBcXwZaRtYuIoPq"
}'
{
"access_token": "pat_9f2c1a7b4e5d6f8a0b1c2d3e4f5a6b7c",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "orders:read orders:write"
}
Сохраните токен в переменную:
export TOKEN=pat_9f2c1a7b4e5d6f8a0b1c2d3e4f5a6b7c
2. Создать поручение
Клиент бронирует отель в Италии на 1500 EUR. Партнёр PARTNER-777 выставил инвойс.
Обратите внимание на callback_url: указываем встроенный приёмник вебхуков,
чтобы сразу увидеть все события ПА.
curl -X POST http://localhost:3000/v1/orders \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-H 'Idempotency-Key: idmp_728_001' \
-d '{
"order_id": "CSO-2026-728-88421",
"scenario": "INVOICE",
"client": {
"client_id": "BANK-CLI-998877",
"name": "Иванов Иван Иванович",
"phone": "+79991234455"
},
"service": {
"type": "hotel_booking",
"country": "IT",
"amount_local": 1500.00,
"currency_local": "EUR",
"partner_id": "PARTNER-777",
"meta": { "check_in": "2026-08-15", "check_out": "2026-08-20" }
},
"invoice": {
"invoice_id": "INV-P-001",
"file_url": "https://cso.bank.ru/docs/inv001.pdf"
},
"deadline": "2026-07-29T12:00:00Z",
"callback_url": "http://localhost:3000/v1/sandbox/webhook-sink"
}'
{
"pa_order_id": "PA-ORD-2026-A1B2C3D4",
"status": "DRAFT",
"created_at": "2026-07-28T10:30:00.000Z",
"expires_at": "2026-07-29T10:30:00.000Z"
}
export PA_ORDER_ID=PA-ORD-2026-A1B2C3D4
3. Взять в работу
curl -X PATCH http://localhost:3000/v1/orders/$PA_ORDER_ID/assign \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{ "assignee": "operator-14" }'
Статус становится IN_PROGRESS, запускается SLA-таймер по deadline.
4. Зафиксировать курс и получить реквизиты
Один вызов проводит поручение через FX_FIXED в AWAITING_PAYMENT.
curl -X POST http://localhost:3000/v1/orders/$PA_ORDER_ID/payment-details \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{}'
{
"status": "AWAITING_PAYMENT",
"fx": {
"rate": 97.35,
"fixed_at": "2026-07-28T10:35:00.000Z",
"expires_at": "2026-07-29T10:35:00.000Z"
},
"amount_rub": 146025.00,
"payment_details": {
"type": "bank_transfer",
"recipient_name": "ООО \"Платежный Агент\"",
"inn": "7701234567",
"account": "40702810100000001234",
"bank_bik": "044525225",
"purpose": "Оплата по поручению CSO-2026-728-88421. НДС не облагается.",
"uin": "CSO72888421"
},
"commission": { "rate_percent": 2.5, "amount_rub": 3650.63, "vat_included": false }
}
Эти реквизиты ЦСО передаёт клиенту. УИН обязателен в назначении платежа —
по нему идёт автоматическая сверка банковской выписки.
Курс живёт 24 часа. Если оплата не поступит, поручение уйдёт в EXPIRED.
5. Сымитировать оплату клиента
В бою этот шаг делает банк, присылая строку выписки. В sandbox — одна строка:
curl -X POST http://localhost:3000/v1/sandbox/orders/$PA_ORDER_ID/simulate-client-payment \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{}'
ПА автоматически проходит CLIENT_PAID → FX_EXECUTED → PARTNER_PAID:
{
"status": "PARTNER_PAID",
"payment_out": {
"type": "swift",
"swift_ref": "SWIFT-2026-2796F3E4",
"iban_to": "DE89370400440532013000",
"amount_sent": 1500.00,
"currency_sent": "EUR",
"fee": 35.00,
"value_date": "2026-07-29",
"receipt_url": "https://pa-docs.example.com/swift/SWIFT-2026-2796F3E4.pdf"
}
}
6. Закрыть поручение
curl -X POST http://localhost:3000/v1/sandbox/orders/$PA_ORDER_ID/simulate-service-confirmation \
-H "Authorization: Bearer $TOKEN"
Статус — COMPLETED.
7. Посмотреть, что прислал ПА
Все вебхуки, отправленные на callback_url:
curl http://localhost:3000/v1/sandbox/webhook-sink \
-H "Authorization: Bearer $TOKEN"
Каждая запись содержит signature_valid: true — подпись сошлась.
Полная история переходов:
curl http://localhost:3000/v1/orders/$PA_ORDER_ID/events \
-H "Authorization: Bearer $TOKEN"
order.created
order.assigned DRAFT -> IN_PROGRESS
order.fx_fixed IN_PROGRESS -> FX_FIXED
order.payment_details_issued FX_FIXED -> AWAITING_PAYMENT
order.client_paid AWAITING_PAYMENT -> CLIENT_PAID
order.fx_executed CLIENT_PAID -> FX_EXECUTED
order.partner_paid FX_EXECUTED -> PARTNER_PAID
order.completed PARTNER_PAID -> COMPLETED
Одной командой
Тот же сценарий целиком:
./scripts/demo-invoice.sh
Сценарий с платёжной формой партнёра:
./scripts/demo-payment-link.sh
Дальше
- Аутентификация — как правильно работать с токенами и идемпотентностью
- Вебхуки — как принять события у себя и проверить подпись
Раздел 02
Аутентификация
Способ 1: OAuth 2.0 Client Credentials (рекомендуется)
client_id и client_secret выдаются на онбординге.
curl -X POST https://pa-api.partner-domain.com/v1/oauth/token \
-H 'Content-Type: application/json' \
-d '{
"grant_type": "client_credentials",
"client_id": "cso-prod",
"client_secret": "<секрет>"
}'
{
"access_token": "pat_9f2c1a7b4e5d6f8a0b1c2d3e4f5a6b7c",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "orders:read orders:write"
}
Дальше во всех запросах:
Authorization: Bearer pat_9f2c1a7b4e5d6f8a0b1c2d3e4f5a6b7c
Токен живёт час. Запрашивайте новый заранее — например, за пять минут до истечения,
и кэшируйте его в памяти процесса, а не получайте на каждый запрос.
class PaTokenProvider {
private token?: { value: string; expiresAt: number };
async get(): Promise<string> {
if (this.token && this.token.expiresAt > Date.now() + 300_000) {
return this.token.value;
}
const response = await fetch(`${this.baseUrl}/v1/oauth/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
grant_type: 'client_credentials',
client_id: this.clientId,
client_secret: this.clientSecret,
}),
});
if (!response.ok) {
throw new Error(`Не удалось получить токен ПА: ${response.status}`);
}
const grant = await response.json();
this.token = {
value: grant.access_token,
expiresAt: Date.now() + grant.expires_in * 1000,
};
return this.token.value;
}
constructor(
private readonly baseUrl: string,
private readonly clientId: string,
private readonly clientSecret: string,
) {}
}
Способ 2: статический API-ключ
Запасной вариант — для служебных задач и первичной отладки:
curl https://pa-api.partner-domain.com/v1/orders -H 'X-API-Key: <ключ>'
Ключ не имеет срока жизни, поэтому его утечка опаснее. Для регулярного трафика
используйте OAuth.
Транспорт
В боевом контуре обязателен mTLS. Сертификат клиента выдаётся вместе с учётными
данными; проверка идёт на уровне API Gateway до попадания запроса в приложение.
Идемпотентность
POST /v1/orders требует заголовок Idempotency-Key. Это защищает от двойного
поручения при сетевых ретраях.
Idempotency-Key: idmp_728_001
Правила:
- Ответ на первый успешный запрос кэшируется на 24 часа.
- Повтор с тем же ключом и тем же телом вернёт исходный ответ и заголовок
Idempotent-Replay: true.
- Повтор с тем же ключом, но другим телом — ошибка
409 IDEMPOTENCY_KEY_REUSED. - Ключ должен быть уникален для каждого поручения. Подойдёт UUID или ваш
order_id.
Дополнительно ПА защищён на уровне бизнес-логики: повторное создание поручения
с уже известным order_id вернёт существующее поручение, а не создаст дубль.
Ограничение частоты
| Клиент | Лимит |
|---|---|
| ЦСО | 1000 запросов в минуту |
| Партнёр | 100 запросов в минуту |
При превышении — 429 с кодом RATE_LIMIT_EXCEEDED. Повторяйте с экспоненциальной
задержкой, ориентируясь на заголовок Retry-After.
Идентификатор запроса
В каждом ответе есть заголовок X-Request-Id; он же попадает в тело ошибки как
request_id. Логируйте его — это первое, что спросит поддержка.
Можно передать свой:
X-Request-Id: req_our-trace-id-42
Что дальше
Раздел 03
Сценарий 8.1: инвойс
Партнёр выставил инвойс в своей валюте. Клиент платит рубли на счёт ПА,
ПА конвертирует и отправляет партнёру SWIFT MT103.
Подходит для: бронирования отелей, оплаты договоров, услуг с выставлением счёта.
Последовательность
Шаг 1. Создание поручения
Для INVOICE блок invoice обязателен.
POST /v1/orders
Authorization: Bearer <token>
Idempotency-Key: idmp_728_001
Content-Type: application/json
{
"order_id": "CSO-2026-728-88421",
"scenario": "INVOICE",
"client": {
"client_id": "BANK-CLI-998877",
"name": "Иванов Иван Иванович",
"phone": "+79991234455"
},
"service": {
"type": "hotel_booking",
"country": "IT",
"amount_local": 1500.00,
"currency_local": "EUR",
"partner_id": "PARTNER-777",
"meta": { "check_in": "2026-08-15", "check_out": "2026-08-20", "guests": 2 }
},
"invoice": {
"invoice_id": "INV-P-001",
"file_url": "https://cso.bank.ru/docs/inv001.pdf",
"file_hash_sha256": "a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90",
"due_date": "2026-08-01"
},
"deadline": "2026-07-29T12:00:00Z",
"callback_url": "https://cso.bank.ru/webhook/pa-events"
}
Поля client.name и client.phone шифруются AES-256-GCM при сохранении. В ответах
API и в логах они присутствуют только в маскированном виде: Иванов И.И.,
+7 * * 44 55.
Реквизиты выплаты партнёру (IBAN, SWIFT BIC) в поручении не передаются — они
зафиксированы в ПА на онбординге партнёра. Достаточно partner_id.
Шаг 2. Взять в работу
PATCH /v1/orders/{pa_order_id}/assign
{ "assignee": "operator-14" }
DRAFT → IN_PROGRESS.
Если чего-то не хватает, запросите документы:
POST /v1/orders/{pa_order_id}/request-docs
{
"required_documents": ["passport_scan", "booking_confirmation"],
"comment": "Нужен разворот паспорта гостя"
}
IN_PROGRESS → PENDING_DOCS, ЦСО получает вебхук со списком. После загрузки
любого документа через POST /v1/orders/{id}/documents поручение автоматически
возвращается в IN_PROGRESS.
Шаг 3. Курс и реквизиты
POST /v1/orders/{pa_order_id}/payment-details
{}
Один вызов делает два перехода: IN_PROGRESS → FX_FIXED → AWAITING_PAYMENT.
{
"status": "AWAITING_PAYMENT",
"fx": { "rate": 97.35, "fixed_at": "...", "expires_at": "..." },
"amount_rub": 146025.00,
"payment_details": {
"type": "bank_transfer",
"recipient_name": "ООО \"Платежный Агент\"",
"inn": "7701234567",
"account": "40702810100000001234",
"bank_bik": "044525225",
"purpose": "Оплата по поручению CSO-2026-728-88421. НДС не облагается.",
"uin": "CSO72888421"
}
}
Если клиенту удобнее платёжная ссылка, запросите её явно:
{ "type": "payment_link" }
Важно про УИН. uin детерминированно выводится из order_id
(CSO-2026-728-88421 → CSO72888421) и должен без изменений попасть в назначение
платежа. Сверка выписки идёт по паре УИН + сумма; платёж без УИН потребует ручного
разбора.
Важно про срок. Курс действует 24 часа (fx.expires_at). Если рубли поступят
позже, поручение перейдёт в EXPIRED, и потребуется новое поручение с новым курсом.
Шаг 4. Поступление рублей
Клиент платит на счёт ПА по выданным реквизитам (не на счёт партнёра и не
зарубежной компании). Банк счёта ПА присылает строку выписки:
POST /v1/callbacks/bank/statement
X-PA-Signature: sha256=<hex>
X-PA-Timestamp: 1785235500
{
"uin": "CSO72888421",
"amount_rub": 146025.00,
"received_at": "2026-07-28T11:05:00Z",
"statement_ref": "ST-2026-07-28-0001"
}
ПА сверяет УИН и сумму. Сумма должна совпадать до копейки, иначе — 422
с кодом PAYMENT_AMOUNT_MISMATCH. Повторная доставка того же события безопасна:
поручение уже в CLIENT_PAID, ответ вернётся тот же.
Шаг 5. Конвертация и SWIFT
Дальше ПА действует сам, без вызовов со стороны ЦСО:
CLIENT_PAID→FX_EXECUTED— покупка валюты по зафиксированному курсу.FX_EXECUTED→PARTNER_PAID— отправка MT103 на IBAN партнёра.
ЦСО получает событие order.partner_paid:
{
"event_type": "order.partner_paid",
"pa_order_id": "PA-ORD-2026-A1B2C3D4",
"data": {
"status": "PARTNER_PAID",
"swift_ref": "SWIFT-2026-2796F3E4",
"amount_sent": 1500.00,
"currency_sent": "EUR",
"fee_deducted": 35.00,
"partner_receives": 1465.00,
"value_date": "2026-07-29",
"documents": [
{ "type": "SWIFT_CONFIRMATION", "url": "https://pa-docs.example.com/swift/SWIFT-2026-2796F3E4.pdf" }
]
}
}
Комиссия банка-корреспондента (fee_deducted) удерживается из суммы перевода:
партнёр получает amount_sent - fee_deducted. Учитывайте это при согласовании
суммы с партнёром.
Шаг 6. Подтверждение услуги
Партнёр сообщает, что услуга оказана:
POST /v1/callbacks/partner/service-confirmed
X-PA-Signature: sha256=<hex>
X-PA-Timestamp: 1785239100
{
"pa_order_id": "PA-ORD-2026-A1B2C3D4",
"comment": "Бронь подтверждена, ваучер отправлен гостю"
}
PARTNER_PAID → COMPLETED. Партнёр может дополнительно приложить ваучер:
POST /v1/callbacks/partner/documents
{
"pa_order_id": "PA-ORD-2026-A1B2C3D4",
"document_type": "SERVICE_VOUCHER",
"file_name": "voucher-88421.pdf",
"file_url": "https://partner-777.example.com/docs/voucher-88421.pdf"
}
Отмена и возврат
| Ситуация | Метод | Результат |
|---|---|---|
| Клиент передумал до оплаты | PATCH /v1/orders/{id}/cancel | REJECTED |
| Деньги пришли, услуга не состоялась | POST /v1/orders/{id}/refund | REFUND_INITIATED → REFUNDED |
После CLIENT_PAID отмена недоступна — только возврат. Порядок учёта курсовой
разницы согласуется отдельно.
Готовый пример
./scripts/demo-invoice.sh
Раздел 04
Сценарий 8.2: ссылка
Партнёр не выставляет инвойс — у него платёжная страница или форма бронирования.
Клиент платит рубли на счёт ПА, а ПА оплачивает на форме партнёра иностранной картой.
Подходит для: срочных броней — авиабилеты, рестораны, мероприятия, трансферы.
Чем отличается от сценария 8.1
| 8.1 INVOICE | 8.2 PAYMENT_LINK | |
|---|---|---|
| Основание | Инвойс партнёра | Ссылка на форму партнёра |
| Обязательное поле | invoice | payment_link |
| Выплата партнёру | SWIFT MT103 | Оплата иностранной картой на форме |
| Подтверждение | SWIFT-confirmation | Чек и код авторизации |
| Комиссия банка | Удерживается из перевода | Отсутствует |
| Скорость выплаты | Дата валютирования T+1 | Мгновенно |
Статусная модель и все остальные шаги совпадают.
Последовательность
Шаг 1. Создание поручения
Ссылку на форму партнёра оператор консьержа получает от партнёра или формирует сам.
POST /v1/orders
Authorization: Bearer <token>
Idempotency-Key: idmp_728_002
{
"order_id": "CSO-2026-728-90001",
"scenario": "PAYMENT_LINK",
"client": {
"client_id": "BANK-CLI-771122",
"name": "Петров Пётр Петрович",
"phone": "+79995558877"
},
"service": {
"type": "restaurant_booking",
"country": "TR",
"amount_local": 8500.00,
"currency_local": "TRY",
"partner_id": "PARTNER-301",
"meta": { "date": "2026-08-03T20:00:00+03:00", "guests": 4 }
},
"payment_link": "https://partner-301.example.com/pay/8f3a2b1c",
"deadline": "2026-07-29T18:00:00Z",
"callback_url": "https://cso.bank.ru/webhook/pa-events"
}
Без payment_link вернётся 422 PAYMENT_LINK_REQUIRED.
Партнёр должен поддерживать этот сценарий. Список доступных партнёров:
curl http://localhost:3000/v1/sandbox/partners -H "Authorization: Bearer $TOKEN"
Если партнёр работает только по инвойсам, создание вернёт
422 SCENARIO_NOT_SUPPORTED_BY_PARTNER.
Шаг 2. Реквизиты для клиента
PATCH /v1/orders/{pa_order_id}/assign
POST /v1/orders/{pa_order_id}/payment-details
Для PAYMENT_LINK по умолчанию выдаётся платёжная ссылка, а не реквизиты счёта:
{
"status": "AWAITING_PAYMENT",
"fx": { "rate": 2.49, "fixed_at": "...", "expires_at": "..." },
"amount_rub": 21165.00,
"payment_details": {
"type": "payment_link",
"url": "https://pa-api.partner-domain.com/pay/PA-ORD-2026-EDDAE05B?uin=CSO72890001",
"uin": "CSO72890001",
"account": "40702810100000001234",
"purpose": "Оплата по поручению CSO-2026-728-90001. НДС не облагается."
}
}
Реквизиты счёта возвращаются всегда — на случай, если клиент предпочтёт обычный
перевод. Чтобы принудительно получить только банковские реквизиты, передайте
{"type": "bank_transfer"}.
Из-за срочности таких броней ставьте deadline в несколько часов: просроченное
поручение уйдёт в EXPIRED автоматически.
Шаг 3. Оплата на форме партнёра
После поступления рублей ПА:
CLIENT_PAID→FX_EXECUTED— покупает валюту.FX_EXECUTED→PARTNER_PAID— оплачивает на форме партнёра иностранной картой (3-D Secure).
{
"status": "PARTNER_PAID",
"payment_out": {
"type": "card",
"partner_payment_url": "https://partner-301.example.com/pay/8f3a2b1c",
"amount_sent": 8500.00,
"currency_sent": "TRY",
"fee": 0,
"auth_code": "199681",
"receipt_url": "https://pa-docs.example.com/receipts/PA-ORD-2026-EDDAE05B-199681.pdf"
}
}
auth_code — код авторизации карты. Именно его партнёр использует для сверки
транзакции на своей стороне.
Шаг 4. Подтверждение
Как и в сценарии 8.1:
POST /v1/callbacks/partner/service-confirmed
{ "pa_order_id": "PA-ORD-2026-EDDAE05B", "comment": "Стол забронирован" }
Что делать, если оплата на форме не прошла
Поручение остаётся в CLIENT_PAID — деньги клиента у ПА, но партнёр не оплачен.
Такое поручение попадает оператору в разбор. Возможные действия:
- повторить выплату после устранения причины (например, партнёр починил форму);
- вернуть средства клиенту через
POST /v1/orders/{id}/refund.
Проверить, что именно произошло, можно в журнале событий:
curl http://localhost:3000/v1/orders/$PA_ORDER_ID/events -H "Authorization: Bearer $TOKEN"
Готовый пример
./scripts/demo-payment-link.sh
Раздел 05
Вебхуки
ПА уведомляет о каждом изменении поручения. Опрашивать GET /v1/orders/{id}
не нужно — но можно как страховку при разборе инцидентов.
Конверт события
Все события приходят в одном формате:
{
"spec_version": "1.0",
"event_id": "evt_a1b2c3d4e5f60718",
"event_type": "order.status_changed",
"timestamp": "2026-07-28T10:35:00.000Z",
"pa_order_id": "PA-ORD-2026-A1B2C3D4",
"cso_order_id": "CSO-2026-728-88421",
"data": {
"status": "AWAITING_PAYMENT",
"status_prev": "FX_FIXED",
"fx_rate": 97.35,
"amount_rub": 146025.00,
"payment_details": {
"type": "bank_transfer",
"account": "40702810100000001234",
"purpose": "Оплата по поручению CSO-2026-728-88421. НДС не облагается.",
"uin": "CSO72888421"
}
}
}
Заголовки:
| Заголовок | Значение |
|---|---|
X-PA-Event-Id | Идентификатор события, совпадает с event_id в теле |
X-PA-Event-Type | Тип события |
X-PA-Delivery-Attempt | Номер попытки доставки, начиная с 1 |
X-PA-Signature | sha256=<hex> |
X-PA-Timestamp | Unix-время подписи |
Типы событий
| Событие | Когда |
|---|---|
order.status_changed | Любой переход статуса |
order.partner_paid | Партнёр оплачен, приложены подтверждающие документы |
Партнёр дополнительно получает на свой адрес:
| Событие | Когда |
|---|---|
payment_required | Клиенту выданы реквизиты, оплата ожидается |
partner_paid | Средства отправлены партнёру |
Проверка подписи
Подписывается строка ${timestamp}.${raw_body} алгоритмом HMAC-SHA256.
Проверяйте сырое тело запроса до парсинга JSON — пересборка объекта изменит
порядок полей и пробелы, подпись не сойдётся.
Node.js / TypeScript (Express)
import express from 'express';
import { createHmac, timingSafeEqual } from 'node:crypto';
const app = express();
const SECRET = process.env.PA_WEBHOOK_SECRET!;
const TOLERANCE_SECONDS = 300;
function isValid(rawBody: Buffer, signature?: string, timestamp?: string): boolean {
if (!signature || !timestamp) return false;
const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
if (!Number.isFinite(age) || age > TOLERANCE_SECONDS) return false;
const expected = `sha256=${createHmac('sha256', SECRET)
.update(`${timestamp}.${rawBody.toString('utf8')}`)
.digest('hex')}`;
const a = Buffer.from(expected);
const b = Buffer.from(signature);
return a.length === b.length && timingSafeEqual(a, b);
}
// Важно: raw, а не express.json()
app.post('/webhook/pa-events', express.raw({ type: 'application/json' }), (req, res) => {
if (!isValid(req.body, req.header('x-pa-signature'), req.header('x-pa-timestamp'))) {
return res.status(401).send('invalid signature');
}
const event = JSON.parse(req.body.toString('utf8'));
// Отвечаем сразу, обработку уводим в очередь: у ПА таймаут 10 секунд.
res.status(200).send('ok');
void enqueue(event);
});
Python (FastAPI)
import hmac
import hashlib
import time
from fastapi import FastAPI, Request, Response
app = FastAPI()
SECRET = os.environ["PA_WEBHOOK_SECRET"].encode()
TOLERANCE_SECONDS = 300
def is_valid(raw_body: bytes, signature: str | None, timestamp: str | None) -> bool:
if not signature or not timestamp:
return False
try:
age = abs(int(time.time()) - int(timestamp))
except ValueError:
return False
if age > TOLERANCE_SECONDS:
return False
digest = hmac.new(
SECRET, f"{timestamp}.".encode() + raw_body, hashlib.sha256
).hexdigest()
return hmac.compare_digest(f"sha256={digest}", signature)
@app.post("/webhook/pa-events")
async def handle(request: Request):
raw_body = await request.body()
if not is_valid(
raw_body,
request.headers.get("x-pa-signature"),
request.headers.get("x-pa-timestamp"),
):
return Response(status_code=401)
event = json.loads(raw_body)
await enqueue(event)
return Response(status_code=200)
PHP
<?php
$secret = getenv('PA_WEBHOOK_SECRET');
$rawBody = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_PA_SIGNATURE'] ?? '';
$timestamp = $_SERVER['HTTP_X_PA_TIMESTAMP'] ?? '';
if (abs(time() - (int) $timestamp) > 300) {
http_response_code(401);
exit;
}
$expected = 'sha256=' . hash_hmac('sha256', $timestamp . '.' . $rawBody, $secret);
if (!hash_equals($expected, $signature)) {
http_response_code(401);
exit;
}
$event = json_decode($rawBody, true);
http_response_code(200);
Требования к вашему обработчику
Отвечайте быстро. Таймаут — 10 секунд. Верните 200 сразу после проверки
подписи, а бизнес-логику выполняйте асинхронно.
Считайте 2xx подтверждением. Любой другой код или таймаут — повод для повтора.
Будьте идемпотентны. Одно и то же событие может прийти повторно. Дедуплицируйте
по event_id.
Не полагайтесь на порядок. События приходят почти всегда по порядку, но при
ретраях порядок может нарушиться. Ориентируйтесь на data.status и timestamp,
а не на последовательность доставки.
Повторы и DLQ
При неуспехе ПА повторяет доставку с экспоненциальной задержкой:
| Попытка | Задержка |
|---|---|
| 1 | сразу |
| 2 | +1 с |
| 3 | +2 с |
| 4 | +4 с |
| 5 | +8 с |
После пяти неудачных попыток событие уходит в Dead Letter Queue.
Посмотреть историю доставки по поручению:
curl "http://localhost:3000/v1/webhooks/deliveries?pa_order_id=$PA_ORDER_ID" \
-H "Authorization: Bearer $TOKEN"
{
"items": [
{
"event_id": "evt_a1b2c3d4e5f60718",
"event_type": "order.status_changed",
"status": "DELIVERED",
"attempts": 1,
"response_status": 200,
"created_at": "2026-07-28T10:35:00.000Z"
}
]
}
Недоставленные события и ручной повтор:
curl http://localhost:3000/v1/webhooks/dead-letters -H "Authorization: Bearer $TOKEN"
curl -X POST http://localhost:3000/v1/webhooks/deliveries/evt_a1b2c3d4e5f60718/replay \
-H "Authorization: Bearer $TOKEN"
Отправка вебхуков в ПА
Партнёр и банк отправляют события в ПА по той же схеме подписи, своим секретом.
import { createHmac } from 'node:crypto';
function signedHeaders(body: unknown, secret: string): Record<string, string> {
const payload = JSON.stringify(body);
const timestamp = Math.floor(Date.now() / 1000).toString();
return {
'Content-Type': 'application/json',
'X-PA-Timestamp': timestamp,
'X-PA-Signature': `sha256=${createHmac('sha256', secret)
.update(`${timestamp}.${payload}`)
.digest('hex')}`,
};
}
const body = { pa_order_id: 'PA-ORD-2026-A1B2C3D4', comment: 'Услуга оказана' };
await fetch('https://pa-api.partner-domain.com/v1/callbacks/partner/service-confirmed', {
method: 'POST',
headers: signedHeaders(body, process.env.PA_PARTNER_SECRET!),
// Тело должно совпадать байт в байт с тем, что подписали
body: JSON.stringify(body),
});
Проверить свою подпись, не поднимая инфраструктуру, поможет
Раздел 06
Статусы поручения
Поручение — центральный объект интеграции. Все переходы атомарны и пишутся
в журнал событий; переход вне схемы отклоняется с ошибкой 409.
Схема переходов
Справочник
| Статус | Что произошло | Что делает ПА | Что делать ЦСО |
|---|---|---|---|
DRAFT | Поручение создано | Валидирует поля, ждёт захвата | Вызвать assign |
IN_PROGRESS | Взято в работу | Запускает SLA-таймер | Запросить реквизиты |
PENDING_DOCS | Не хватает документов | Ждёт файлы | Загрузить документы |
FX_FIXED | Курс зафиксирован | Держит курс 24 часа | Ничего, переход автоматический |
AWAITING_PAYMENT | Реквизиты выданы | Сверяет выписку по УИН | Передать реквизиты клиенту |
CLIENT_PAID | Рубли поступили | Запускает конвертацию | Ничего |
FX_EXECUTED | Валюта куплена | Готовит выплату | Ничего |
PARTNER_PAID | Партнёр получил оплату | Ждёт подтверждения услуги | Уведомить клиента |
COMPLETED | Услуга подтверждена | Закрывает поручение | Финализировать у себя |
REJECTED | Отменено | Освобождает резервы | Сообщить клиенту |
EXPIRED | Истёк срок | Освобождает резервы | Создать новое поручение |
REFUND_INITIATED | Начат возврат | Считает сумму возврата | Ничего |
REFUNDED | Возврат исполнен | Закрывает поручение | Сообщить клиенту |
Терминальные статусы: COMPLETED, REJECTED, EXPIRED, REFUNDED. Из них
переходов нет.
Кто инициирует переход
| Переход | Инициатор |
|---|---|
DRAFT → IN_PROGRESS | ЦСО: PATCH /assign |
IN_PROGRESS → PENDING_DOCS | ЦСО: POST /request-docs |
PENDING_DOCS → IN_PROGRESS | ЦСО: POST /documents (автоматически) |
IN_PROGRESS → FX_FIXED → AWAITING_PAYMENT | ЦСО: POST /payment-details |
AWAITING_PAYMENT → CLIENT_PAID | Банк: вебхук выписки |
CLIENT_PAID → FX_EXECUTED → PARTNER_PAID | ПА, автоматически |
PARTNER_PAID → COMPLETED | Партнёр: вебхук подтверждения |
Любой → REJECTED | ЦСО: PATCH /cancel |
Любой → EXPIRED | ПА, по расписанию |
CLIENT_PAID+ → REFUND_INITIATED → REFUNDED | ЦСО: POST /refund |
Ошибка недопустимого перехода
{
"error": {
"code": "INVALID_STATUS_TRANSITION",
"message": "Переход DRAFT -> COMPLETED не разрешён статусной моделью",
"details": {
"current_status": "DRAFT",
"requested_status": "COMPLETED",
"allowed_next": ["IN_PROGRESS", "PENDING_DOCS", "REJECTED", "EXPIRED"]
},
"request_id": "req_0f6b2c1e-7d3a-4f21-9a0b-1c2d3e4f5a6b",
"timestamp": "2026-07-28T10:35:00.000Z"
}
}
Поле allowed_next подсказывает, что вызвать вместо этого.
Журнал событий
Полная история переходов поручения:
curl http://localhost:3000/v1/orders/$PA_ORDER_ID/events \
-H "Authorization: Bearer $TOKEN"
[
{
"event_id": "evt_a1b2c3d4e5f60718",
"event_type": "order.fx_fixed",
"status_from": "IN_PROGRESS",
"status_to": "FX_FIXED",
"actor": "cso:cso-sandbox",
"payload": { "rate": 97.35, "amount_rub": 146025.00 },
"created_at": "2026-07-28T10:35:00.000Z"
}
]
Журнал append-only: записи не изменяются и не удаляются. Это источник истины
при разборе спорных ситуаций.
Раздел 07
Ошибки
Формат
Все ошибки возвращаются одинаково:
{
"error": {
"code": "PAYMENT_AMOUNT_MISMATCH",
"message": "Поступившая сумма не совпадает с ожидаемой",
"details": {
"expected_rub": 146025.00,
"received_rub": 100.00
},
"request_id": "req_0f6b2c1e-7d3a-4f21-9a0b-1c2d3e4f5a6b",
"timestamp": "2026-07-28T11:05:00.000Z"
}
}
Ветвитесь по code. Поле message предназначено для людей и может меняться
без предупреждения. details зависит от кода и помогает понять, что исправить.
request_id указывайте при обращении в поддержку.
Справочник кодов
Аутентификация и доступ
| Код | HTTP | Причина | Что делать |
|---|---|---|---|
UNAUTHORIZED | 401 | Нет или неверен токен/ключ | Получить новый токен |
INVALID_WEBHOOK_SIGNATURE | 401 | Подпись входящего вебхука не сошлась | Проверить секрет и что подписано сырое тело |
RATE_LIMIT_EXCEEDED | 429 | Превышен лимит запросов | Повторить с экспоненциальной задержкой |
INVALID_WEBHOOK_SIGNATURE уточняет причину в details.reason:
MISSING_HEADERS, STALE_TIMESTAMP или BAD_SIGNATURE.
Валидация запроса
| Код | HTTP | Причина | Что делать |
|---|---|---|---|
VALIDATION_ERROR | 400 | Поля не прошли проверку | Смотреть details.violations |
IDEMPOTENCY_KEY_REQUIRED | 400 | Нет заголовка Idempotency-Key | Добавить заголовок |
IDEMPOTENCY_KEY_REUSED | 409 | Тот же ключ с другим телом | Использовать новый ключ |
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Запрос не прошёл валидацию",
"details": {
"violations": [
"service.currency_local: ожидается код валюты из трёх заглавных букв",
"deadline must be a valid ISO 8601 date string"
]
}
}
}
Поручение
| Код | HTTP | Причина | Что делать |
|---|---|---|---|
ORDER_NOT_FOUND | 404 | Нет поручения с таким pa_order_id | Проверить идентификатор |
ORDER_NOT_FOUND_BY_UIN | 404 | Нет поручения с таким УИН | Проверить назначение платежа |
ORDER_ALREADY_EXISTS | 409 | Гонка при создании с тем же order_id | Запросить поручение через GET |
INVALID_STATUS_TRANSITION | 409 | Действие недопустимо в текущем статусе | Смотреть details.allowed_next |
ORDER_IS_TERMINAL | 422 | Поручение закрыто | Создать новое поручение |
Сценарий и партнёр
| Код | HTTP | Причина | Что делать |
|---|---|---|---|
INVOICE_REQUIRED | 422 | Для INVOICE не передан блок invoice | Добавить инвойс |
PAYMENT_LINK_REQUIRED | 422 | Для PAYMENT_LINK не передана ссылка | Добавить payment_link |
PARTNER_NOT_REGISTERED | 422 | Партнёр неизвестен ПА | Проверить partner_id, инициировать онбординг |
SCENARIO_NOT_SUPPORTED_BY_PARTNER | 422 | Партнёр не работает по этому сценарию | Смотреть details.supported_scenarios |
PARTNER_PAYOUT_DETAILS_MISSING | 422 | У партнёра нет IBAN для SWIFT | Обратиться в поддержку ПА |
PARTNER_PAYMENT_LINK_MISSING | 422 | Нет ссылки для оплаты на форме | Пересоздать поручение со ссылкой |
Курс и оплата
| Код | HTTP | Причина | Что делать |
|---|---|---|---|
CURRENCY_NOT_SUPPORTED | 422 | Валюта вне списка | Смотреть details.supported |
FX_RATE_NOT_FIXED | 422 | Курс ещё не зафиксирован | Вызвать POST /payment-details |
FX_RATE_EXPIRED | 422 | Курс истёк (24 часа) | Создать новое поручение |
PAYMENT_AMOUNT_MISMATCH | 422 | Сумма не совпала до копейки | Разобрать платёж вручную |
Служебные
| Код | HTTP | Причина |
|---|---|---|
WEBHOOK_NOT_FOUND | 404 | Вебхука нет в DLQ |
SANDBOX_DISABLED | 404 | Sandbox выключен в этом окружении |
INTERNAL_ERROR | 500 | Внутренняя ошибка ПА |
Как обрабатывать
4xx, кроме 429, — не повторять. Запрос некорректен, ретрай ничего не изменит.
Исключение: 409 INVALID_STATUS_TRANSITION может означать, что параллельный
процесс уже продвинул поручение — сначала прочитайте актуальное состояние через
GET /v1/orders/{id}.
429 и 5xx — повторять с экспоненциальной задержкой и обязательно с тем же
Idempotency-Key, чтобы не создать дубль.
async function callPa<T>(request: () => Promise<Response>): Promise<T> {
const delays = [1000, 2000, 4000, 8000, 16000];
for (let attempt = 0; attempt <= delays.length; attempt += 1) {
const response = await request();
if (response.ok) {
return response.json() as Promise<T>;
}
const retriable = response.status === 429 || response.status >= 500;
if (!retriable || attempt === delays.length) {
const { error } = await response.json();
throw new PaApiError(error.code, error.message, error.request_id);
}
await sleep(delays[attempt]);
}
throw new Error('unreachable');
}
Раздел 08
Sandbox
Контур для самостоятельной отладки: можно пройти оба сценария целиком, не поднимая
банк, не отправляя SWIFT и не поднимая собственный обработчик вебхуков.
Включается переменной SANDBOX_ENABLED=true. В продуктиве всегда выключен —
запросы к /v1/sandbox/* вернут 404 SANDBOX_DISABLED.
Демо-партнёры
curl http://localhost:3000/v1/sandbox/partners -H "Authorization: Bearer $TOKEN"
partner_id | Партнёр | Страна | Валюта | Сценарии |
|---|---|---|---|---|
PARTNER-777 | Grand Hotel Roma S.r.l. | IT | EUR | INVOICE |
PARTNER-512 | Dubai Luxury Transfers LLC | AE | AED | INVOICE, PAYMENT_LINK |
PARTNER-301 | Istanbul Fine Dining | TR | TRY | PAYMENT_LINK |
PARTNER-301 удобен, чтобы проверить обработку SCENARIO_NOT_SUPPORTED_BY_PARTNER:
попробуйте создать для него поручение со сценарием INVOICE.
Курсы валют
Курсы детерминированы, поэтому примеры воспроизводимы. Итоговый курс — базовый
плюс наценка FX_MARKUP_PERCENT (по умолчанию 1.5%), округлённый до копеек.
| Валюта | Базовый курс | Итоговый |
|---|---|---|
| EUR | 95.91 | 97.35 |
| USD | 88.50 | 89.83 |
| AED | 24.10 | 24.46 |
| TRY | 2.45 | 2.49 |
| CNY | 12.30 | 12.48 |
Также поддерживаются GBP, HKD, THB, SGD, JPY, MVR. Прочие валюты дадут
422 CURRENCY_NOT_SUPPORTED со списком доступных.
Имитация оплаты клиента
Заменяет вебхук банковской выписки. Поручение проходит CLIENT_PAID →
FX_EXECUTED → PARTNER_PAID за один вызов.
curl -X POST http://localhost:3000/v1/sandbox/orders/$PA_ORDER_ID/simulate-client-payment \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{}'
Без amount_rub берётся ожидаемая сумма поручения. Чтобы проверить обработку
расхождения, передайте другую сумму — получите 422 PAYMENT_AMOUNT_MISMATCH:
-d '{ "amount_rub": 100.00 }'
Имитация подтверждения услуги
curl -X POST http://localhost:3000/v1/sandbox/orders/$PA_ORDER_ID/simulate-service-confirmation \
-H "Authorization: Bearer $TOKEN"
PARTNER_PAID → COMPLETED.
Приёмник вебхуков
Укажите его как callback_url при создании поручения — и увидите всё, что
отправляет ПА, включая результат проверки подписи.
{ "callback_url": "http://localhost:3000/v1/sandbox/webhook-sink" }
curl http://localhost:3000/v1/sandbox/webhook-sink -H "Authorization: Bearer $TOKEN"
{
"items": [
{
"received_at": "2026-07-28T10:35:00.123Z",
"event_id": "evt_a1b2c3d4e5f60718",
"event_type": "order.status_changed",
"signature": "sha256=8f7d3a1b...",
"timestamp": "1785235500",
"signature_valid": true,
"body": { "spec_version": "1.0", "data": { "status": "AWAITING_PAYMENT" } }
}
]
}
Очистить:
curl -X DELETE http://localhost:3000/v1/sandbox/webhook-sink -H "Authorization: Bearer $TOKEN"
Подпись произвольного тела
Помогает отладить отправку входящих вебхуков в ПА: сервис вернёт заголовки,
которые нужно приложить к запросу.
curl -X POST http://localhost:3000/v1/sandbox/sign \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{
"source": "bank",
"body": { "uin": "CSO72888421", "amount_rub": 146025.00 }
}'
{
"headers": {
"X-PA-Signature": "sha256=8f7d3a1b2c4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8",
"X-PA-Timestamp": "1785235500"
},
"signed_body": "{\"uin\":\"CSO72888421\",\"amount_rub\":146025}"
}
Отправляйте ровно signed_body — пересборка JSON изменит байты и подпись
не сойдётся.
curl -X POST http://localhost:3000/v1/callbacks/bank/statement \
-H 'Content-Type: application/json' \
-H "X-PA-Signature: $SIG" \
-H "X-PA-Timestamp: $TS" \
-d "$SIGNED_BODY"
source выбирает секрет: bank для выписки, partner для событий партнёра.
Что проверить перед боевым запуском
Полный список — в чек-листе перед продом.
Раздел 09