Payment Agent API

Как подключиться к Payment Agent

Один сайт со всеми гайдами: быстрый старт, оба сценария оплаты, вебхуки с примерами, статусы, ошибки и чек-лист перед продом.

8.1 INVOICE · SWIFT MT103 8.2 PAYMENT_LINK · карта HMAC · OAuth2 · Sandbox

Раздел 00

Обзор

Платежный агент (ПА) принимает рубли от клиента консьерж-сервиса и рассчитывается

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

к API — со стороны ЦСО и со стороны партнёра.

Единый сайт для клиента: откройте index.html в браузере —

все разделы в одном файле с меню слева. Пересобрать после правок markdown:

npm run docs:partner

Что делает ПА

sequenceDiagram participant CSO as ЦСО participant PA as Платежный агент participant Client as Клиент participant Partner as Зарубежный партнёр CSO->>PA: Поручение (инвойс или ссылка партнёра) PA->>PA: Фиксация курса PA-->>CSO: Реквизиты в рублях с УИН Client->>PA: Оплата в рублях PA->>PA: Конвертация RUB в валюту PA->>Partner: SWIFT MT103 или оплата на форме Partner-->>PA: Подтверждение услуги PA-->>CSO: Статус COMPLETED и документы

Разделы

ДокументО чём
Быстрый стартПервое поручение за 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.0
  • http://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_PAIDFX_EXECUTEDPARTNER_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.

Подходит для: бронирования отелей, оплаты договоров, услуг с выставлением счёта.

Последовательность

sequenceDiagram participant CSO as ЦСО participant PA as ПА participant Client as Клиент participant BankNR as Банк-нерезидент participant Partner as Партнёр CSO->>PA: POST /v1/orders (scenario INVOICE) PA-->>CSO: 201 pa_order_id, DRAFT CSO->>PA: PATCH /assign CSO->>PA: POST /payment-details PA->>PA: Фиксация курса, генерация УИН PA-->>CSO: Реквизиты ПА в рублях + УИН, AWAITING_PAYMENT Client->>PA: Перевод рублей на счёт ПА (УИН в назначении) Note over PA: Выписка банка счёта ПА → POST /v1/callbacks/bank/statement PA->>PA: Сверка по УИН, CLIENT_PAID PA->>BankNR: Покупка валюты, FX_EXECUTED BankNR->>Partner: SWIFT MT103 на IBAN партнёра PA-->>CSO: order.partner_paid + подтверждение SWIFT Partner->>PA: POST /v1/callbacks/partner/service-confirmed PA-->>CSO: order.status_changed COMPLETED

Шаг 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" }

DRAFTIN_PROGRESS.

Если чего-то не хватает, запросите документы:

POST /v1/orders/{pa_order_id}/request-docs
{
  "required_documents": ["passport_scan", "booking_confirmation"],
  "comment": "Нужен разворот паспорта гостя"
}

IN_PROGRESSPENDING_DOCS, ЦСО получает вебхук со списком. После загрузки

любого документа через POST /v1/orders/{id}/documents поручение автоматически

возвращается в IN_PROGRESS.

Шаг 3. Курс и реквизиты

POST /v1/orders/{pa_order_id}/payment-details
{}

Один вызов делает два перехода: IN_PROGRESSFX_FIXEDAWAITING_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-88421CSO72888421) и должен без изменений попасть в назначение

платежа. Сверка выписки идёт по паре УИН + сумма; платёж без УИН потребует ручного

разбора.

Важно про срок. Курс действует 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

Дальше ПА действует сам, без вызовов со стороны ЦСО:

  1. CLIENT_PAIDFX_EXECUTED — покупка валюты по зафиксированному курсу.
  2. FX_EXECUTEDPARTNER_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_PAIDCOMPLETED. Партнёр может дополнительно приложить ваучер:

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}/cancelREJECTED
Деньги пришли, услуга не состояласьPOST /v1/orders/{id}/refundREFUND_INITIATEDREFUNDED

После CLIENT_PAID отмена недоступна — только возврат. Порядок учёта курсовой

разницы согласуется отдельно.

Готовый пример

./scripts/demo-invoice.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-Signaturesha256=<hex>
X-PA-TimestampUnix-время подписи

Типы событий

СобытиеКогда
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),
});

Проверить свою подпись, не поднимая инфраструктуру, поможет

sandbox.

Раздел 06

Статусы поручения

Поручение — центральный объект интеграции. Все переходы атомарны и пишутся

в журнал событий; переход вне схемы отклоняется с ошибкой 409.

Схема переходов

stateDiagram-v2 [*] --> DRAFT DRAFT --> IN_PROGRESS: взято в работу DRAFT --> PENDING_DOCS: запрошены документы PENDING_DOCS --> IN_PROGRESS: документы получены IN_PROGRESS --> PENDING_DOCS: запрошены документы IN_PROGRESS --> FX_FIXED: курс зафиксирован FX_FIXED --> AWAITING_PAYMENT: реквизиты выданы AWAITING_PAYMENT --> CLIENT_PAID: поступили рубли CLIENT_PAID --> FX_EXECUTED: конвертация выполнена FX_EXECUTED --> PARTNER_PAID: партнёр оплачен PARTNER_PAID --> COMPLETED: услуга подтверждена DRAFT --> REJECTED PENDING_DOCS --> REJECTED IN_PROGRESS --> REJECTED FX_FIXED --> REJECTED AWAITING_PAYMENT --> REJECTED DRAFT --> EXPIRED PENDING_DOCS --> EXPIRED IN_PROGRESS --> EXPIRED FX_FIXED --> EXPIRED AWAITING_PAYMENT --> EXPIRED CLIENT_PAID --> REFUND_INITIATED FX_EXECUTED --> REFUND_INITIATED PARTNER_PAID --> REFUND_INITIATED REFUND_INITIATED --> REFUNDED COMPLETED --> [*] REJECTED --> [*] EXPIRED --> [*] REFUNDED --> [*]

Справочник

СтатусЧто произошлоЧто делает ПАЧто делать ЦСО
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. Из них

переходов нет.

Кто инициирует переход

ПереходИнициатор
DRAFTIN_PROGRESSЦСО: PATCH /assign
IN_PROGRESSPENDING_DOCSЦСО: POST /request-docs
PENDING_DOCSIN_PROGRESSЦСО: POST /documents (автоматически)
IN_PROGRESSFX_FIXEDAWAITING_PAYMENTЦСО: POST /payment-details
AWAITING_PAYMENTCLIENT_PAIDБанк: вебхук выписки
CLIENT_PAIDFX_EXECUTEDPARTNER_PAIDПА, автоматически
PARTNER_PAIDCOMPLETEDПартнёр: вебхук подтверждения
Любой → REJECTEDЦСО: PATCH /cancel
Любой → EXPIREDПА, по расписанию
CLIENT_PAID+ → REFUND_INITIATEDREFUNDEDЦСО: 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ПричинаЧто делать
UNAUTHORIZED401Нет или неверен токен/ключПолучить новый токен
INVALID_WEBHOOK_SIGNATURE401Подпись входящего вебхука не сошласьПроверить секрет и что подписано сырое тело
RATE_LIMIT_EXCEEDED429Превышен лимит запросовПовторить с экспоненциальной задержкой

INVALID_WEBHOOK_SIGNATURE уточняет причину в details.reason:

MISSING_HEADERS, STALE_TIMESTAMP или BAD_SIGNATURE.

Валидация запроса

КодHTTPПричинаЧто делать
VALIDATION_ERROR400Поля не прошли проверкуСмотреть details.violations
IDEMPOTENCY_KEY_REQUIRED400Нет заголовка Idempotency-KeyДобавить заголовок
IDEMPOTENCY_KEY_REUSED409Тот же ключ с другим теломИспользовать новый ключ
{
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "Запрос не прошёл валидацию",
    "details": {
      "violations": [
        "service.currency_local: ожидается код валюты из трёх заглавных букв",
        "deadline must be a valid ISO 8601 date string"
      ]
    }
  }
}

Поручение

КодHTTPПричинаЧто делать
ORDER_NOT_FOUND404Нет поручения с таким pa_order_idПроверить идентификатор
ORDER_NOT_FOUND_BY_UIN404Нет поручения с таким УИНПроверить назначение платежа
ORDER_ALREADY_EXISTS409Гонка при создании с тем же order_idЗапросить поручение через GET
INVALID_STATUS_TRANSITION409Действие недопустимо в текущем статусеСмотреть details.allowed_next
ORDER_IS_TERMINAL422Поручение закрытоСоздать новое поручение

Сценарий и партнёр

КодHTTPПричинаЧто делать
INVOICE_REQUIRED422Для INVOICE не передан блок invoiceДобавить инвойс
PAYMENT_LINK_REQUIRED422Для PAYMENT_LINK не передана ссылкаДобавить payment_link
PARTNER_NOT_REGISTERED422Партнёр неизвестен ПАПроверить partner_id, инициировать онбординг
SCENARIO_NOT_SUPPORTED_BY_PARTNER422Партнёр не работает по этому сценариюСмотреть details.supported_scenarios
PARTNER_PAYOUT_DETAILS_MISSING422У партнёра нет IBAN для SWIFTОбратиться в поддержку ПА
PARTNER_PAYMENT_LINK_MISSING422Нет ссылки для оплаты на формеПересоздать поручение со ссылкой

Курс и оплата

КодHTTPПричинаЧто делать
CURRENCY_NOT_SUPPORTED422Валюта вне спискаСмотреть details.supported
FX_RATE_NOT_FIXED422Курс ещё не зафиксированВызвать POST /payment-details
FX_RATE_EXPIRED422Курс истёк (24 часа)Создать новое поручение
PAYMENT_AMOUNT_MISMATCH422Сумма не совпала до копейкиРазобрать платёж вручную

Служебные

КодHTTPПричина
WEBHOOK_NOT_FOUND404Вебхука нет в DLQ
SANDBOX_DISABLED404Sandbox выключен в этом окружении
INTERNAL_ERROR500Внутренняя ошибка ПА

Как обрабатывать

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-777Grand Hotel Roma S.r.l.ITEURINVOICE
PARTNER-512Dubai Luxury Transfers LLCAEAEDINVOICE, PAYMENT_LINK
PARTNER-301Istanbul Fine DiningTRTRYPAYMENT_LINK

PARTNER-301 удобен, чтобы проверить обработку SCENARIO_NOT_SUPPORTED_BY_PARTNER:

попробуйте создать для него поручение со сценарием INVOICE.

Курсы валют

Курсы детерминированы, поэтому примеры воспроизводимы. Итоговый курс — базовый

плюс наценка FX_MARKUP_PERCENT (по умолчанию 1.5%), округлённый до копеек.

ВалютаБазовый курсИтоговый
EUR95.9197.35
USD88.5089.83
AED24.1024.46
TRY2.452.49
CNY12.3012.48

Также поддерживаются GBP, HKD, THB, SGD, JPY, MVR. Прочие валюты дадут

422 CURRENCY_NOT_SUPPORTED со списком доступных.

Имитация оплаты клиента

Заменяет вебхук банковской выписки. Поручение проходит CLIENT_PAID

FX_EXECUTEDPARTNER_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_PAIDCOMPLETED.

Приёмник вебхуков

Укажите его как 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

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

Онбординг

Секреты

Реализация на вашей стороне

Обработчик вебхуков

Бизнес-логика

Наблюдаемость

Нагрузка

Приёмка