Перейти к основному содержимому

mTLS, JWS-подпись webhooks и AsyncAPI с outbox

Разработчику Архитектору Аналитику

Зачем третья глава

ГлаваФокус
117REST, контракт, партнёрский API заказов, HMAC-ошибки
1171Публичный API, OAuth 2.0, webhooks с shared secret
Эта главаРегулируемый B2B: mTLS на транспорте, JWS на теле webhook, AsyncAPI на поток событий, outbox — чтобы событие не потерялось после commit в БД

Сценарий: банк-партнёр подключается к платформе выплат. Требования: взаимная аутентификация по сертификату, неотрекаемость доставки уведомлений, аудит по eventId, согласованный каталог сообщений до деплоя.


Слои доверия — что где проверять

  • mTLS отвечает на вопрос «кто установил TCP/TLS-соединение» (сертификат клиента).
  • OAuth / scope (если есть) — «какие операции разрешены этому client_id».
  • JWS на webhook — «это тело не менялось после отправки платформой» (асимметричная подпись, ротация ключей через JWKS).

Подробнее про сертификаты: 8.07.112. HMAC из 1171 проще, но у крупных партнёров часто требуют асимметрию (платформа подписывает приватным ключом, банк проверяет публичным).


mTLS — проектирование доступа

Регистрация клиента

В консоли партнёра заводят запись:

ПолеПримерЗачем
partnerIdbank_alfaключ в логах и rate limit
clientCertSubjectCNpayments-api.bank.exampleсопоставление с сертификатом
allowedIPsопциональнодополнительный фильтр
environmentsandbox / prodразные trust store

Сертификат выпускает ваш CA или принимается сертификат из доверенного УЦ партнёра — политика фиксируется в договоре.


Терминация TLS

Типичная схема:

  1. API Gateway / Ingress завершает mTLS, прокидывает в заголовках X-Client-Cert-Subject или SPIFFE ID.
  2. Сервис приложения не парсит PEM в каждом handler — идентичность уже проверена на краю.

Партнёр вызывает:

GET /v1/payouts/po_7f3c9a2e HTTP/1.1
Host: api.payments.example
# TLS: клиентский сертификат bank_alfa (clientAuth)
Accept: application/json

Ответ 403 без валидного клиентского сертификата — ожидаемое поведение; тело ошибки в том же формате, что в 117.


Ротация сертификатов

  • Перекрытие сроков: новый cert работает до отзыва старого (30–90 дней).
  • В документации — fingerprint SHA-256 и дата notAfter.
  • Алерт за 14 дней до истечения — обязанность обеих сторон.

mTLS дополняет OAuth для machine-to-machine: cert = «это именно инфраструктура банка», token = «этому client разрешён scope payouts:read».


Webhooks с JWS — вместо одного shared secret

Заголовки доставки

ЗаголовокЗначение
Content-Typeapplication/json
X-Webhook-Idevt_01JABC123 (идемпотентность — 213)
X-Webhook-Timestamp1716816005
X-Webhook-SignatureeyJhbGciOiJFERTQSIs...compact JWS над каноническим payload

Что подписываем

Строка для подписи (псевдоконтракт):

{timestamp}.{raw_body_bytes}

raw_body_bytes — тело HTTP до JSON.parse. Партнёр повторяет ту же конкатенацию и проверяет JWS алгоритмом из заголовка JWS (alg: EdDSA, RS256).

Пример тела:

{
"id": "evt_01JABC123",
"type": "payout.completed",
"createdAt": "2026-05-27T14:00:00Z",
"data": {
"payoutId": "po_7f3c9a2e",
"amount": { "value": "1500.00", "currency": "RUB" },
"status": "completed"
}
}

Платформа публикует JWKS по GET https://api.payments.example/.well-known/webhook-jwks.json — ротация kid без смены URL у партнёра.


Проверка на стороне банка (псевдокод)


import { createRemoteJWKSet, compactVerify } from 'jose';

const JWKS = createRemoteJWKSet(
new URL('https://api.payments.example/.well-known/webhook-jwks.json')
);

async function verifyWebhook(rawBody, timestamp, jwsCompact, maxSkewSec = 300) {
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > maxSkewSec) {
throw new Error('timestamp skew');
}
const signingInput = new TextEncoder().encode(`${timestamp}.${rawBody}`);
const { payload } = await compactVerify(jwsCompact, JWKS);
const signed = new TextDecoder().decode(payload);
if (signed !== new TextDecoder().decode(signingInput)) {
throw new Error('payload mismatch');
}
}

Партнёр по-прежнему отвечает 2xx за секунды, тяжёлую обработку кладёт во внутреннюю очередь — как в 1171.


AsyncAPI — контракт на события

OpenAPI описывает синхронные HTTP-вызовы. AsyncAPI — каналы, сообщения, схемы payload для Kafka/RabbitMQ и для исходящих webhooks как «канал доставки».

Фрагмент AsyncAPI 3:

asyncapi: 3.0.0
info:
title: Payments Platform Events
version: 1.0.0
channels:
payoutEvents:
address: payments.payout.v1
messages:
payoutCompleted:
name: payout.completed
payload:
$ref: '#/components/schemas/PayoutCompleted'
partnerWebhook:
address: https://bank.example/hooks/payments
messages:
payoutCompletedWebhook:
name: payout.completed
payload:
$ref: '#/components/schemas/PayoutCompleted'
components:
schemas:
PayoutCompleted:
type: object
required: [id, type, createdAt, data]
properties:
id: { type: string }
type: { const: payout.completed }
createdAt: { type: string, format: date-time }
data:
type: object
required: [payoutId, amount, status]

Практика:

  • одна JSON Schema на PayoutCompleted — и в Kafka, и в webhook (нет расхождения полей);
  • ревью AsyncAPI до изменения producer;
  • генерация типов (TypeScript, Java) и тестовых фикстур.

Синхронный REST по-прежнему в OpenAPI — 7.08.3.


Transactional outbox — от commit до webhook

Проблема: UPDATE payouts SET status='completed' закоммичен, а публикация в Kafka или HTTP webhook упала — партнёр не узнает о выплате.


Шаг 1 — одна транзакция в БД

BEGIN;
UPDATE payouts SET status = 'completed', updated_at = now()
WHERE id = 'po_7f3c9a2e';

INSERT INTO outbox (id, aggregate_type, aggregate_id, event_type, payload, created_at)
VALUES (
'evt_01JABC123',
'payout',
'po_7f3c9a2e',
'payout.completed',
'{"payoutId":"po_7f3c9a2e","amount":{"value":"1500.00","currency":"RUB"},"status":"completed"}',
now()
);
COMMIT;

Таблица outbox — источник правды до успешной доставки. Паттерн разобран в Saga / Outbox и событийной архитектуре.


Шаг 2 — relay в брокер

Worker читает published_at IS NULL, публикует в payments.payout.v1, помечает строку:

UPDATE outbox SET published_at = now() WHERE id = 'evt_01JABC123';

Несколько инстансов — FOR UPDATE SKIP LOCKED (пример в 5.03 Java / outbox).


Шаг 3 — delivery worker → webhook

Отдельный consumer (или тот же relay с веткой) строит конверт по AsyncAPI, подписывает JWS, шлёт POST на URL подписки банка. При 5xx — retry с backoff; при исчерпании — DLQ и алерт partnerId=bank_alfa.

Идемпотентность: банк хранит evt_01JABC123 72 часа; повторная доставка → 200 без повторного списания в их учёте.


Сводка — когда что включать

Требование партнёраМеханизм
Доступ только с их ДЦ, без паролей в конфигеmTLS
Scope и аудит по приложениюOAuth2 client credentials + mTLS
Неотрекаемость и ротация ключей подписиJWS + JWKS
Согласованный каталог событийAsyncAPI 3
Не потерять событие после записи в БДTransactional outbox → broker → webhook

Чеклист перед production

  1. Trust store sandbox/prod разделены; тестовый cert не принимается в prod.
  2. JWKS содержит минимум два kid при ротации.
  3. AsyncAPI и OpenAPI версионируются вместе (info.version, changelog).
  4. Outbox мониторится: возраст непубликованных строк, DLQ webhook.
  5. Пентест / 8.07.128 включает replay webhook и отсутствие alg: none в JWS.

Смежные материалы

ТемаСтатья
Партнёрский REST (заказы)7.06.117
OAuth и HMAC-webhooks7.06.1171
Outbox в Saga7.06.2124
Kafka в интеграции2.09.123
Маршрут по главам API7.06.117

См. также

Другие статьи этого же раздела в боковом меню (как на странице "О разделе").