> ## Documentation Index
> Fetch the complete documentation index at: https://veriqa.app/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Свой канальный адаптер

> Подключите канал, которого нет в поставке — KakaoTalk, Viber, внутренний мессенджер — кодом своего хоста, без форка ядра.

Veriqa поставляет четыре канала, но канальный слой — открытая точка расширения. Если ваши
пользователи живут в мессенджере, которого нет в списке, вы пишете адаптер против MIT-пакета
контрактов и регистрируете его в своём хосте. Без форка ядра, без патчей и без ожидания
пул-реквеста.

Адаптер — это один класс и один вызов регистрации:

```csharp Program.cs theme={null}
adapters.AddChannel(
    "acme-chat",
    sp => new AcmeChatChannelAdapter(options, sp.GetRequiredService<ILogger<AcmeChatChannelAdapter>>()));
```

По этому вызову Veriqa регистрирует адаптер, мапит его вебхук с теми же защитами, что у встроенных
каналов, и показывает канал в окне входа.

Полный рабочий пример — в `samples/dotnet/custom-channel`: библиотека адаптера и хост, который её
подключает.

Не пишете на .NET или работаете со стандартным образом Veriqa, который не пересобираете? Напишите
адаптер отдельным сервисом на любом языке и подключите его конфигурацией — см.
[HTTP-адаптер канала на любом языке](#http-адаптер-канала-на-любом-языке).

## 1. Проект адаптера

В зависимостях от Veriqa — только пакет контрактов:

```xml Acme.Chat.Adapter.csproj theme={null}
<ItemGroup>
  <PackageReference Include="Veriqa.Core.Contracts" Version="0.6.0" />
</ItemGroup>
```

`Veriqa.Core.Contracts` — пакет под лицензией MIT: это и есть бесплатный SPI. Состав пакета и то,
какие соседние по namespace типы остаются в MPL-2.0-ядре, — в README пакета (он же виден на
странице пакета в NuGet). Ядро сервера (MPL-2.0) — зависимость *хоста*, а не вашего адаптера.

## 2. Реализация `IChannelAdapter`

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

### `ValidateWebhookAsync` — гейт безопасности

Вызывается раньше вашей обработки, но не раньше всего: сначала запрос проходит демультиплексор
тенантов, который на неизвестном сегменте тенанта отвечает **403**, вообще не обращаясь к адаптеру,
и только после этого Veriqa читает тело и собирает конверт. Вернули `false` — запрос получает **403** и до вашей обработки не доходит;
исключение отсюда трактуется так же, как отказ (уходит в лог, ответ — **403**), поэтому сломанный
разбор подписи никогда не превратится в `500`. Проверяйте подпись платформы здесь, за постоянное
время, и отказывайте по умолчанию:

```csharp theme={null}
public Task<Result<bool>> ValidateWebhookAsync(ChannelInboundRequest request, CancellationToken cancellationToken = default)
{
    if (string.IsNullOrEmpty(_options.WebhookSecret))
    {
        return Task.FromResult(Result<bool>.Success(false));
    }

    request.Headers.TryGetValue("X-Acme-Signature", out var signature);
    signature ??= string.Empty;
    var isValid = CryptographicOperations.FixedTimeEquals(
        Encoding.UTF8.GetBytes(signature),
        Encoding.UTF8.GetBytes(_options.WebhookSecret));

    return Task.FromResult(Result<bool>.Success(isValid));
}
```

### Объявите уровень проверки своего канала — и задокументируйте его

То, что ваш `ValidateWebhookAsync` вернул успех, ядру говорит только «проверка выполнилась и
прошла». **Чем** она была, ядро не знает и вывести не может: закрытого перечня каналов у него нет,
а самозаявление адаптера ничего не гарантировало бы — заявляет тот, кто несёт риск, то есть
интегратор.

Поэтому секция вашего канала несёт тот же обязательный ключ `InboundVerification`, что и секции
поставляемых каналов:

```json appsettings.json theme={null}
{
  "Veriqa": {
    "Channels": {
      "acme-chat": {
        "InboundVerification": "shared_secret"
      }
    }
  }
}
```

Словарь значений — `signature` (подпись тела ключом платформы), `shared_secret` (секрет,
предъявляемый входящим запросом), `outbound_fetch` (входящих запросов нет, события забирает
исходящий запрос) и `none` (подлинность не проверяется); полное описание — в
[справочнике конфигурации](/docs/ru/reference/configuration).

<Note>
  **Для канала, подключённого показанным здесь способом, ключ обязателен, но на старте не
  проверяется.** Проверка, роняющая хост на включённом канале без значения, перебирает
  каналы, объявившие себя на уровне ядра, — тот же набор, из которого ядро строит список включённых
  каналов. Вызов `AddChannel` регистрирует адаптер и его маршрут, но объявления на уровне ядра не
  создаёт, поэтому в этот набор такой канал не входит — пока вы не объявите его сами, как это делает
  раздел [«Работа по тенантам»](#работа-по-тенантам); с этого момента проверка распространяется и на
  ваш канал наравне с каналом поставки. Значение при этом читается тем же резолвером
  и так же доезжает до записи журнала аудита, поэтому пропуск проявится пустым атрибутом в журнале,
  а не отказом при запуске.
</Note>

**Обязанность автора адаптера — задокументировать, какое значение даёт его канал** в каждом режиме,
который он поддерживает. Интегратор выбирает значение по вашей документации: сам он вашу проверку
не читает, а ядро её не выводит. Пример выше даёт `shared_secret`, потому что показанная выше
проверка сравнивает заголовок с настроенным секретом; канал, забирающий события своим исходящим
запросом, объявляет `outbound_fetch`.

### `ProcessInboundEventAsync` — на входе сырое тело, на выходе намерение

Оба метода получают **один и тот же конверт** `ChannelInboundRequest`: HTTP-метод, сырые **байты**
тела, заголовки и параметры строки запроса (имена сравниваются без учёта регистра, повторяющиеся
значения склеены через `", "`). Байты — именно байты: подпись считается ровно по ним, и между
платформой и вашим HMAC не встаёт шаг декодирования. Формат тела Veriqa не интерпретирует — JSON,
form-encoded, XML, что угодно; разбор своими опциями сериализации — ваша обязанность.

```csharp theme={null}
public Task<Result<ChannelInboundResult>> ProcessInboundEventAsync(ChannelInboundRequest request, CancellationToken cancellationToken = default)
{
    var payload = JsonSerializer.Deserialize<AcmeChatWebhookPayload>(request.Body.Span, PayloadJsonOptions);

    var snapshot = new ChannelIdentitySnapshot
    {
        ChannelType = "acme-chat",
        IsBot = false,
        ChannelUserId = payload.UserId,
        DisplayName = payload.DisplayName,
        CapturedAt = DateTimeOffset.UtcNow,
        AdapterVersion = "1.0.0"
    };

    // Идентификатор транзакции приходит строкой в событии канала — разбирает его адаптер:
    // поле результата типизировано, и пустое значение в него не попадает по конструкции.
    // Не разобралось — событие не наше: ChannelUnrelatedResult, а не «пустой id».
    if (!TransactionId.TryParse(payload.TransactionId, out var transactionId))
    {
        return Task.FromResult(Result<ChannelInboundResult>.Success(new ChannelUnrelatedResult()));
    }

    return Task.FromResult(Result<ChannelInboundResult>.Success(
        new ChannelAuthStartResult { TransactionId = transactionId, Identity = snapshot }));
}
```

Намерение — это **тип** результата, и у каждого свой обязательный набор полей:
`ChannelAuthStartResult` (пользователь пришёл на платформу по диплинку),
`ChannelAuthConfirmResult` / `ChannelAuthDeclineResult` (нажал кнопку в вашем канале),
`ChannelPhoneSharedResult` / `ChannelPhoneStepActionResult` (ответы на
[запрос номера телефона](#телефон-по-запросу)), `ChannelUnaddressedResult` или
`ChannelUnrelatedResult`.

Последние два похожи, и их легко перепутать. `ChannelUnaddressedResult` — это **личное сообщение
вашему боту от отправителя, которого вы уже проверили** (голая стартовая команда, произвольный
текст), не называющее транзакции; оно несёт отправителя, поэтому Veriqa может ответить на него, если
регистрация объявила ответ (`UnaddressedReplyText` ниже). `ChannelUnrelatedResult` — всё, отвечать на
что Veriqa не вправе: групповой чат, callback чужой кнопки, событие, не прошедшее вашу собственную
проверку отправителя. Отправителя оно не несёт, и ответа не будет.

Рядом есть синхронный `bool OwnsInboundEvent(ChannelInboundRequest request)` — дешёвый ответ «это
апдейт Veriqa?» **до** разбора. Он нужен, если транспортом канала владеете вы и раздаёте апдейты
между своим ботом и Veriqa: разбирать событие дважды не придётся. Тело по умолчанию уже есть —
поиск любого маркера `CallbackDataPrefixes` (`vq_confirm_`, `vq_decline_`, `vq_phone_skip_`,
`vq_phone_cancel_`, `auth_`) в сырых байтах тела, — поэтому реализовывать член не обязательно. Ответ односторонний: событие от кнопки или
диплинка Veriqa даёт `true` всегда, но `true` сам по себе не обещает, что событие обрабатываемо, —
поиск подстрочный, и `auth_` вендорного префикса не несёт, так что слово `oauth_` в сообщении
пользователя тоже даст `true`. Если ложное срабатывание вам чем-то стоит — переопределите член
точной проверкой своего wire-формата.

### `Capabilities` — что ваш канал умеет

Возможности канала объявляются **фактом**, а не заглушкой операции: один объект
`ChannelCapabilities` говорит, умеет ли канал подтверждать вход **у себя внутри**
(`SupportsInChannelConfirmation`), какие виды контента он принимает, умеет ли обновлять своё
сообщение (`SupportsMessageUpdate`), показывает ли пользователю терминальный статус транзакции
(`DeliversOutcomeNotice`), отдаёт ли локаль получателя (`ProvidesRecipientLocale`) и рендерит ли
эмодзи (`RendersEmoji`). Veriqa не вызывает того, что канал не объявил, — поэтому «не умею» пишется
один раз и в одном месте:

О подтверждении вы сообщаете ровно один факт — собственную способность задать вопрос и получить
ответ внутри канала. Как именно вы это делаете (inline-кнопки, веб внутри мессенджера, что угодно) —
ваше дело, ядро способ не знает и не спрашивает. Поверхности **ядра** адаптер не называет: нужно ли
подтверждение вообще — политика ядра, а страницу с вопросом, когда канал подтверждать у себя не
умеет, показывает само ядро. Дефолт факта — «не умею»: собранный адаптер, который об этом факте
ничего не знает, не получит подтверждение, которое не сумеет показать.

```csharp theme={null}
public ChannelCapabilities Capabilities => new()
{
    // false — подтверждать у себя не умею; вопрос задаст страница ядра.
    // Достаточно не указывать свойство: это его значение по умолчанию.
    SupportsInChannelConfirmation = false,
    SupportedMessageKinds = new[] { ChannelMessageKind.PlainText }.ToFrozenSet(),
    SupportsMessageUpdate = false,
    DeliversOutcomeNotice = false,
    ProvidesRecipientLocale = false,
    // null — правила не заявляю, и ядро формулирует квитанцию так же, как для платформы,
    // рендер эмодзи у которой не гарантирован. true — канал их рендерит, и терминальный текст
    // формулируется как для мессенджера.
    RendersEmoji = null
};
```

`RendersEmoji` — единственный трёхзначный факт объекта: он решает, какой из двух in-channel-формулировок
адресуется терминальная квитанция, и это правило **канала**, а не точки показа. Не указали — сказали
«собственного правила нет»: тогда за канал отвечает список мессенджеров, для которых продукт
поставляет адаптер, а стороннего канала в нём нет.

### Исходящие операции

`SendMessageAsync(ChannelMessage)` отправляет сообщение и возвращает ссылку на отправленное
(`ChannelMessageRef`) — или `null` в успехе, если адресуемой ссылки у канала нет.
`SendConfirmationPromptAsync` отправляет prompt подтверждения и возвращает такую же ссылку;
хранит её Veriqa, а не вы. `ReportOutcomeAsync(TransactionOutcomeNotice)` — намерение «покажи
пользователю терминальный статус транзакции»; какими вызовами платформы это сделать, решаете вы,
и вызывается он только если канал объявил `DeliversOutcomeNotice`. Уведомление везёт ещё и
`DisplayIntent` — где развёртывание **хочет** видеть квитанцию: на месте сообщения с вопросом
(`ReplacePrompt`, поставочное значение) либо отдельным сообщением (`NewMessage`). Исполнять его вы
не обязаны: заменять на вашей платформе может быть нечего, и показ исхода единственным доступным ей
способом ошибкой не считается — в журнал ошибка не пишется, возвращаемый вами результат не меняется.

Исполнение `NewMessage` — это **два** шага, а не один, и забывают обычно второй: доставить квитанцию
новым сообщением **и** снять кнопки у сообщения с вопросом, не трогая его формулировку. Пропустите
второй — и пользователь смотрит на завершённую транзакцию с живой кнопкой под ней: вопрос, который
ничего не решает, но по-прежнему приглашает нажать. Снимайте кнопки той операцией, которая меняет
разметку сообщения, не переписывая его текст; если такой операции у платформы нет, оставьте вопрос
нетронутым, а не обнуляйте его текст — неисполненное намерение ошибкой не считается, а вопрос,
потерявший формулировку, считается. На возвращаемый вами результат снятие кнопок не влияет: его
решает доставка квитанции.

### Телефон по запросу

Канал, который умеет дать номер телефона пользователя, объявляет это в `Capabilities.PhoneNumber`:
как он получает номер и доказывает ли платформа, что номер принадлежит отправителю. Что ядро делает
с номером дальше — когда спрашивает, сколько ждёт, что попадает в токен, — описано на странице
[Номер телефона](/docs/ru/guides/phone-number).

```csharp theme={null}
public ChannelCapabilities Capabilities => new()
{
    SupportedMessageKinds = new[]
    {
        ChannelMessageKind.PlainText,
        ChannelMessageKind.PhoneNumberRequest
    }.ToFrozenSet(),
    PhoneNumber = new PhoneNumberCapability
    {
        // Automatic — номер приходит с сообщениями пользователя; OnRequest — канал его запрашивает.
        // Можно объявить оба сразу.
        Acquisition = PhoneNumberAcquisition.OnRequest,
        // Proven — только если платформа сама привязывает номер к аккаунту отправителя.
        Proof = PhoneNumberProof.Proven
    }
};
```

Не указанный `PhoneNumber` — это `PhoneNumberCapability.None`: канал номера не даёт, и вход, которому
номер обязателен, этот канал не предлагает.

При `Automatic` кладите номер в E.164 в поле снапшота `PhoneNumber`, а доказана ли принадлежность —
в `PhoneNumberVerified` (оставили `null` — claim выйдет как `false`).

При `OnRequest` ядро вызывает `SendMessageAsync` с сообщением вида `PhoneNumberRequest`: `Text` —
текст запроса, а `PhoneNumberRequest` везёт метку кнопки «поделиться номером» (`ShareButtonLabel`),
второе действие (`SecondaryAction` — `Skip` или `CancelSignIn`) с его меткой (`SecondaryButtonLabel`)
и `TransactionId`. Тексты приходят уже локализованными. Покажите кнопку платформы «поделиться
контактом» и рядом вторую кнопку: кнопка с payload несёт `CallbackDataPrefixes.PhoneSkip` или
`CallbackDataPrefixes.PhoneCancel` и следом идентификатор транзакции, а кнопка клавиатуры, которая
умеет только отправлять текст, отправляет свою метку. Ответы возвращайте так:

* присланный контакт — `ChannelPhoneSharedResult` с номером в том виде, в каком его передала
  платформа (к E.164 его приводит ядро), и снапшотом отправителя. Если контакт не прошёл проверку
  принадлежности вашей платформы, поставьте `OwnershipRejected = true`: ядро номер не возьмёт и
  спросит снова. Транзакции контакт не называет — запрос ядро находит по чату;
* вторая кнопка с payload — `ChannelPhoneStepActionResult` с разобранными `TransactionId` и
  `Action`; идентификатор, который не разбирается, делает событие `ChannelUnrelatedResult`;
* вторая кнопка текстом — `ChannelUnaddressedResult` с `Text`: ядро сверит его с меткой, которую
  отправило в этот чат.

Если вы раздаёте апдейты через `OwnsInboundEvent`, переопределите его: тело по умолчанию узнаёт
маркеры кнопок, а у присланного контакта маркера нет. Отвечайте `true` на событие с контактом и
сохраните проверку маркеров вызовом `CallbackDataPrefixes.ContainsAnyMarker(request.Body.Span)` —
тело члена интерфейса по умолчанию из своего класса не вызвать. У кнопки, которая отправляет
обычный текст, маркера нет вовсе, поэтому в этом режиме её нажатие уходит вашему боту: `Optional`
номер тогда пропускается по истечении окна, а `Required` ждёт до истечения транзакции. У встроенного
адаптера Telegram то же ограничение.

### Имена claims — какие ваши, а какие ядра

Всё, что вы знаете о пользователе, кладите в **типизированные поля** снапшота: имя, username,
локаль, телефон и признак доказанной принадлежности (`PhoneNumberVerified`), email и признак его
подтверждённости (`EmailVerified`) — у каждого своё поле. Ядро само выпустит из них claims;
`email_verified` выходит только рядом с непустым email, `phone_number_verified` — только рядом с
непустым номером.
Свободные имена — это `AdditionalClaims`, и там действует дисциплина из трёх групп (реестр
`VeriqaClaimTypes`):

| Группа | Имена | Что с ними |
| - | - | - |
| Обязательные | `channel_type`, `channel_user_id`, `name` | ядро выпускает всегда; из `AdditionalClaims` не принимаются |
| Зарезервированные за ядром | `given_name`, `family_name`, `preferred_username`, `locale`, `phone_number`, `phone_number_verified`, `email`, `email_verified`, `picture`, `is_bot` | выводятся из полей снапшота; из `AdditionalClaims` не принимаются |
| Пространство адаптера | любое имя с префиксом `{ChannelType}_` — например `acme-chat_workspace` | принимаются как есть |

Ключ, нарушивший правило, **отбрасывается с предупреждением в логе** — аутентификация при этом не
падает. Так что «занять» `email` или `locale` своей строкой не выйдет: положите значение в поле
снапшота. Имена сравниваются **без учёта регистра** — `Email_Verified` это то же имя, что
`email_verified`, и смена регистра резервирование не обходит. Префикс своего типа канала делает
коллизии между адаптерами невозможными.

Регистр решает, **чьё** имя, но не даёт свободы написания: свой claim принимается только в
объявленном написании. При `ChannelType = "acme-chat"` ключ `ACME-CHAT_workspace` отбрасывается —
пишите префикс так же, как записан тип канала (`veriqa_channel` — тоже как в реестре). Часть после
префикса ваша: `acme-chat_Workspace` пройдёт. А вот два своих ключа, различающиеся только
регистром, — это **один** claim: один из них возьмётся, второй уедет в лог предупреждением.
Какое из написаний выживет — **не определено**: набор доезжает до проверки замороженным, и
порядок его обхода задаётся раскладкой, а не тем, как вы его записали. Присылайте одно
написание — регистровая пара означает потерю одного из значений.

### Коды ошибок — из объявленного реестра, а не свои строки

Отказ вы возвращаете через `Result<T>.Failure(code, message)`, и `code` берётся из
`VeriqaErrorCodes` — реестра кодов, объявленного в том же MIT-пакете. Своя строка выглядит
работающей, но ею вы разговариваете сами с собой: на объявленных кодах ядро ветвит поведение и
метит телеметрию, а неизвестный код остаётся строкой в логе. Тело неизвестного формата —
`UnsupportedEventType`, тело без нужных полей идентичности — `IdentityExtractionFailed`; семантика
каждого кода — в XML-документации `VeriqaErrorCodes`.

Один код выделен: `ChannelCannotContinue` («провести **это** подтверждение не могу»). Верните его
из `SendConfirmationPromptAsync`, когда канал физически не может задать вопрос по этой транзакции, —
и ядро **завершит транзакцию неуспехом** с этим же кодом причины вместо ожидания до истечения TTL.
Пользователь сразу увидит в браузере сообщение об ошибке, в канал ему при этом ничего не
придёт. Любой другой код отказа означает «не вышло сейчас»: транзакция остаётся ждать ретрая или
своего TTL. Причину вы не сообщаете и на выбор ядра не влияете — вы объявляете только собственную
неспособность, решение принимает ядро.

### `GetDeepLinkAsync` — куда в итоге ведут кнопка и QR-код

Возвращённая ссылка — то, куда ваш канал приводит пользователя. Кнопка канала всегда открывает её
как есть. QR-код несёт её же — если для вашего канала не включён
[hop-режим](/docs/ru/reference/configuration#veriqahopmode) (он действует только для канала,
[задекларированного в хосте](#6-регистрация-в-хосте)): тогда в QR-коде короткая одноразовая
hop-ссылка на ваш сервер Veriqa, и её открытие на телефоне приводит к той же ссылке, что вернул ваш
метод. Для hop-режима в адаптере ничего не меняется — ядро оборачивает ссылку над вашим контрактом.

Пронесите идентификатор транзакции в ссылке, чтобы платформа вернула его в вебхуке:

```csharp theme={null}
public Task<Result<string>> GetDeepLinkAsync(TransactionId transactionId, CancellationToken cancellationToken = default)
    => Task.FromResult(Result<string>.Success($"https://chat.acme.example/start?tx={Uri.EscapeDataString(transactionId)}"));
```

**Вызов должен быть идемпотентным и без побочных эффектов.** Повторный вызов по той же транзакции
возвращает ту же цель и не создаёт состояния: не выпускает новых секретов, ничего не пишет в
хранилище и не публикует событий. Ядро вызывает метод каждый раз, когда показывает ваш канал, — на
странице входа, на странице, где пользователь подтверждения выбирает канал, в ответе
[API серверного подтверждения](/docs/ru/reference/confirmation-api) — и в hop-режиме ещё раз при каждом
открытии hop-ссылки человеком. Метод, который, скажем, выпускает новый одноразовый код на каждом вызове,
превращает каждое обновление страницы и каждое сканирование в ещё один живой секрет. Проверить это в
вашей реализации ядро не может — обещание держите вы.

## 3. Имя канала

Тип канала — контракт, а не отображаемая строка. Он обязан соответствовать паттерну:

```
\A[a-z][a-z0-9-]{0,63}\z
```

Строчный URL-safe токен, начинающийся с латинской буквы: `kakaotalk`, `acme-chat`, `viber`.
Значение сравнивается ординально с `acr_values` и становится сегментом URL-пути, поэтому всё
остальное (`KakaoTalk`, `my_channel`, пустая строка, значение с хвостовым переводом строки из
конфигурации) **валит старт** с сообщением о допустимом формате. Так же fail-fast срабатывает на
повторную регистрацию типа — под неё попадает и тип, уже занятый каналом поставки, потому что
каналы поставки регистрируются этим же `AddChannel` и живут в том же реестре, — и на расхождение
типа, переданного в `AddChannel`, со значением `ChannelType` вашего адаптера — они обязаны совпадать, потому что весь
код ниже находит канал именно по `ChannelType` адаптера. Последняя проверка выполняется при старте
хоста, а не при маппинге вебхуков, поэтому исходящий/polling-канал с `mapWebhook: false` проверяется
ровно так же строго.

<Note>
  У последней проверки есть одна граница: перекрёстную подмену — адаптер одного канала
  зарегистрирован под типом другого — она ловит сверкой рантайм-типов адаптеров. Если хост навешивает
  сквозной декоратор на **каждый** `IChannelAdapter` (Scrutor `Decorate<IChannelAdapter, …>()` или
  Castle-прокси на весь набор), все адаптеры получают один рантайм-тип, а типы обёрнутых скрыты за
  ним: такая подмена становится неотличимой от легального декоратора и **не** отсекается — отлов в
  этой конфигурации best-effort. Для нативных адаптеров и per-adapter class-прокси он точен. Простой
  случай — тип из `AddChannel` не объявлен ни одним адаптером — отсекается всегда, независимо от
  декораторов.
</Note>

## 4. Эндпоинт вебхука

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

```
POST /api/channels/{channelType}/webhook
POST /api/channels/{channelType}/webhook/{tenant}
```

с **той же rate-limit-политикой и тем же лимитом тела в 1 МБ**, что и маршруты встроенных каналов —
generic-путь не позволяет обойти защиты, применённые к `telegram` или `whatsapp`.

Семантика ответов:

| Ситуация | Ответ |
| - | - |
| Неизвестный сегмент тенанта в пути | `403`, ещё до вызова адаптера |
| Тело сверх лимита в 1 МБ — чтение оборвано до валидации | `200` + запись в лог |
| `ValidateWebhookAsync` вернул false, ошибку или бросил исключение | `403` |
| Канал не зарегистрирован | `404` |
| `GET` на generic-путь — канал не объявил verification-handshake | `405` |
| Всё, что после успешной валидации | `200` |

Последняя строка — безусловный инвариант: и `Failure`, и необработанное исключение
вашего адаптера попадают в лог, а ответ всё равно `200` с пустым телом. Правило одно для всех
каналов: маршруты каналов поставки обслуживает тот же обработчик, поэтому и там исключение адаптера
не превращается в `500`. Платформы отключают вебхуки или устраивают ретрай-штормы на non-2xx, а тело
ответа никогда не раскрывает деталей ошибки.

Лимит 1 МБ относится к телу запроса: тело читает сам Veriqa, один раз и целиком в память, при сборке
конверта — после демультиплексора тенантов и до валидации. Превышение лимита это чтение обрывает
по ходу, не дочитывая тело; отказ чтения вердиктом отказа не является, поэтому ответ — `200` и
запись в лог уровня `Warning`, **одинаково на generic-маршруте и на маршрутах встроенных каналов**.
Сверхлимитный апдейт теряется, но платформа не уводит вебхук в бесконечную переотправку; признак
проблемы — запись в логе, а не код ответа.

Если канал только исходящий или сам опрашивает платформу, зарегистрируйте его без маршрута:

```csharp theme={null}
adapters.AddChannel<AcmeChatChannelAdapter>("acme-chat", mapWebhook: false);
```

### Настройки регистрации

`mapWebhook` — единственная настройка, которую короткие перегрузки называют явно. Всё, что
регистрация может объявить, живёт в `ChannelRegistrationOptions`, и обе перегрузки принимают его
вместо флага — и форма с типом адаптера, и форма с фабрикой:

| Свойство | По умолчанию | Что объявляет |
| - | - | - |
| `MapWebhook` | `true` | Мапить ли generic-маршрут вебхука для канала. `false` — канал только исходящий либо опрашивающий платформу сам, либо мапящий собственный входящий маршрут через `IChannelEndpointRegistrar`. |
| `ErrorMessageText` | `"Something went wrong. Go back to the sign-in page and start again."` | Natural Key статусного текста, который увидит пользователь при сбое обработки. Это единственный статусный текст, который объявляет регистрация: терминальный исход транзакции — сообщение механизма и резолвится по адресу, а ошибка, о которой сообщает канал, исходом транзакции не является. |
| `StaleLinkReplyText` | `null` | Natural Key ответа на начало входа, транзакцию которого обслужить нельзя: её нет, срок вышел или (там, где вопрос задаёт веб-страница ядра) решение по ней уже записано. `null` — канал молчит. Текст один на все причины: формулировка не различает «не было» и «истекло». Ответ идёт под тем же per-user лимитом, что и само начало входа, со списанием один раз на событие. |
| `UnaddressedReplyText` | `null` | Natural Key ответа на личное сообщение, не называющее транзакции (`ChannelUnaddressedResult`). `null` — ответа нет. |
| `WebhookVerificationQueryKey` | `null` | Имя query-параметра, несущего challenge verification-handshake платформы (ниже). `null` — у платформы его нет, GET-маршрут не мапится. |

Пустая строка и строка из пробелов равносильны `null`: канал молчит.

Каналы поставки идут ровно этим путём: их статусные формулировки и handshake объявлены здесь же.

Telegram и MAX объявляют `StaleLinkReplyText`, WhatsApp — нет: сообщение вне утверждённого шаблона
там платное, и ответ на каждую протухшую ссылку стоил бы установке денег. Для своего канала решайте
по тарифам его платформы. `UnaddressedReplyText` у всех каналов поставки — `null`: механизм есть, но
ничего не отправляется, пока установка не задаст текст.

### Verification-handshake платформы (GET)

Платформы Meta-семейства подтверждают владение вебхуком **GET**-запросом на тот же путь с challenge,
который нужно вернуть эхом. Объявите query-параметр, который его несёт, — и Veriqa замапит
GET-маршрут рядом с POST:

```csharp theme={null}
adapters.AddChannel<AcmeChatChannelAdapter>(
    "acme-chat",
    new ChannelRegistrationOptions
    {
        WebhookVerificationQueryKey = "hub.challenge",
        ErrorMessageText = "Acme Chat is unavailable"
    });
```

Подлинность handshake — **ваша**: GET валидирует тот же `ValidateWebhookAsync`, что и POST, поэтому
verify-token, написание, под которым он приходит, и проверка режима — дело вашего адаптера. Veriqa
знает только, из какого query-параметра взять значение для эха. Маршрут fail-closed, ровно как
POST-маршрут:

| Ситуация | Ответ |
| - | - |
| Неизвестный сегмент тенанта либо адаптер канала не зарегистрирован | `403` |
| `ValidateWebhookAsync` вернул false, ошибку или бросил исключение | `403` |
| Валидация прошла, но объявленного query-параметра нет или он пуст | `400` |
| Валидация прошла | `200`, тело — challenge |

Не объявили — GET-маршрут не мапится вовсе, и путь отвечает `405`: именно этого хочет канал,
платформа которого handshake не требует.

## 5. Показ канала в окне входа

Зарегистрированный канал уже виден кнопкой — по умолчанию с именем-типом канала и без иконки.
Реализуйте опциональный `IChannelDisplayMetadata`, чтобы задать название и глиф:

```csharp theme={null}
public sealed class AcmeChatChannelAdapter : IChannelAdapter, IChannelDisplayMetadata
{
    public string DisplayName => "Acme Chat";

    public string? IconSvgPath => """<path d="M12 2 3 22h4l1.8-4h6.4l1.8 4h4L12 2z"/>""";
}
```

`DisplayName` — одна нелокализуемая строка: бренд-имена не переводятся, ровно как «Telegram»; пустое
значение — как и длиннее 32 символов, что ломает вёрстку окна, — деградирует до типа канала. `IconSvgPath` — внутренняя разметка глифа во viewBox `24×24`,
она встраивается в страницу, поэтому это должна быть ваша статическая разметка, а не пользовательский
ввод. Перед отрисовкой значение проверяется по allowlist: только простые фигуры (`path`, `circle`,
`ellipse`, `rect`, `line`, `polyline`, `polygon`, `g`) с атрибутами в двойных кавычках, без `style` и
без обработчиков `on*`, длина — не больше 4096 символов. Всё остальное отбрасывается с
предупреждением в лог, а канал отрисовывается без иконки. Канал без фирменного
цвета отрисовывается нейтральным primary-цветом окна.

Статус-сообщения, которые канал отправляет пользователю, берутся из локализованных дефолтов
Veriqa — задавать их не нужно. При входе это «Вход подтверждён» и «Вход отклонён», при
подтверждении действия — нейтральные квитанции исхода «Подтверждено ✅», «Отклонено ❌» и
«Время истекло ⌛». Сообщение об ошибке обработки ответа — «Что-то пошло не так. Вернитесь на страницу входа и начните заново.» — текст канала,
от типа транзакции он не зависит.

### Размер QR-кода

Окно входа рисует QR каждого канала в боксе, размер которого задан CSS-токеном
`--veriqa-qr-size` (по умолчанию 220 px). Переопределяется он вашей таблицей стилей — её путь
задаётся в `Veriqa:AuthPageDesign:CustomCssPath`:

```css theme={null}
/* глобально — для всех каналов */
:root { --veriqa-qr-size: 260px; }

/* только для вашего канала — id панели складывается из типа канала */
#veriqa-panel-acme-chat { --veriqa-qr-size: 260px; }
```

Переопределяйте именно токен: он и есть штатная точка управления размером. Правило вида
`#veriqa-panel-acme-chat .veriqa-qr img { width: … }` тот же результат даёт в обход токена и
поэтому запрещено — при следующем изменении вёрстки окна оно разъедется с остальными размерами.

Уменьшать размер нельзя ниже двух порогов сразу:

* **200×200 px** — минимальный физический размер мишени для камеры;
* **2,86 px на модуль** — плотность: `размер бокса ÷ полное число модулей QR вместе с quiet zone`.

Критерия два, и выполнение одного не заменяет другого: длинный диплинк кодируется QR более
высокой версии, и при том же боксе модуль мельчает до нечитаемого, хотя 200 px формально
соблюдены.

Размер бокса — только половина картинки: вторая половина — разрешение исходного PNG, то есть число
пикселей на модуль. Правило одно, и **проверить его нужно сразу, а не только если вы увеличили
бокс**:

```
пикселей на модуль ≥ размер бокса ÷ число модулей вашего QR (вместе с quiet zone), округлённое вверх
```

Иначе картинку растянет, а сетка модулей размоется.

**Почему это касается и поставляемого бокса 220 px.** Дефолт `PixelsPerModule` = 6 рассчитан на
диплинки, которые поставляет ядро: самый компактный их QR — 45 модулей, и `45 × 6 = 270 px` бокс
покрывают. При боксе 220 px дефолта хватает начиная с `⌈220 ÷ 6⌉ = 37` модулей. Разреженнее этого
QR получается только у **очень короткой** ссылки: `acme://c/8f3k9d` — 33 модуля, `33 × 6 = 198 px`,
растянуто уже в поставляемом боксе. Ссылка из §2, которая проносит идентификатор транзакции
(43 символа), под этот случай не попадает — она даёт 45 модулей, и дефолта хватает. Так что запись
ниже нужна вам, только если вы ведёте собственный короткий код со своим маппингом на транзакцию.
Хост в любом случае стартует: значение в допустимом диапазоне, а число модулей вашего QR ядру
неизвестно — проверить правило можете только вы.

Если это ваш случай, лечится записью для своего канала:

```json appsettings.json theme={null}
{
  "Veriqa": {
    "AuthPageDesign": {
      "QrCode": {
        "PixelsPerModule": 6,
        "PixelsPerModuleByChannel": { "acme-chat": 8 }
      }
    }
  }
}
```

Здесь 8 — это `⌈220 ÷ 29⌉`: 29 модулей у самого компактного QR, какой вообще возможен (версия 1
вместе с quiet zone), поэтому 8 закрывает поставляемый бокс при любой длине вашего диплинка.
Увеличили бокс — пересчитайте это значение по той же формуле.

Допустимый диапазон — **5…35**. Верхняя граница — та же формула для бокса в 1000 px и тех же
29 модулей, поэтому диапазон покрывает любой бокс, помещающийся на экране, при любом канале.
Значение вне диапазона хост не запустит — но попадание в диапазон, наоборот, ничего не подтверждает:
соответствие формуле остаётся на вас.

## 6. Регистрация в хосте

Хост — это приложение, встраивающее Veriqa, поэтому он ссылается на ядро сервера:

```csharp Program.cs theme={null}
builder.Services.AddVeriqaAuthServer(builder.Configuration, builder.Environment, authServer =>
{
    // Хранилище OpenIddict: волатильное in-memory, выбранное явно (умолчания нет — хост,
    // не сказавший здесь ничего, вне Development не стартует).
    authServer.UseInMemoryOpenIddictStore();

    authServer.ConfigureTransactionEngine(te => te.UseInMemoryStore());

    authServer.AddChannelAdapters(adapters =>
    {
        adapters.AddTelegram();

        adapters.AddChannel(
            "acme-chat",
            sp => new AcmeChatChannelAdapter(acmeChatOptions, sp.GetRequiredService<ILogger<AcmeChatChannelAdapter>>()));
    });
});
```

Секция конфигурации вашего канала принадлежит вам: Veriqa её не знает и не биндит. Свяжите её в
хосте и передайте результат адаптеру, а секреты держите в user-secrets или секрет-сторе, не в
`appsettings.json`.

Когда зависимости адаптера резолвятся контейнером, вместо фабричной перегрузки используйте
`AddChannel<TAdapter>("acme-chat")`.

**Задекларируйте канал рядом с `AddChannel`.** `AddChannel` регистрирует адаптер и его маршрут, но
не декларацию канала на уровне ядра (`CoreChannelDeclaration`) — это отдельный шаг, тот же, что
делают поставляемые каналы. Именно декларация вносит канал в `channels_enabled` — набор каналов,
доступных на уровне ядра, и всё, что читает этот набор, пропускает канал вне его:
[hop-режим](/docs/ru/reference/configuration#veriqahopmode) не оборачивает его QR-код, polling не получает
тенантов для запуска, стартовая проверка верификации входящих событий его не касается. Хост при этом
стартует — с предупреждением (Warning) в логе, называющим канал, у которого есть адаптер, но нет
декларации. Как задекларировать канал — в разделе [Работа по тенантам](#работа-по-тенантам).

## 7. Регистрация в списке поддерживаемых каналов

Адаптер, который уезжает в поставку Veriqa, регистрируется ещё в одном месте — в каталоге
[«Поддерживаемые каналы»](/docs/ru/guides/supported-channels) и его версии на сайте
(`veriqa.app/channels`). Список ведётся вручную: канал, которого в нём нет, поддерживаемым не
считается, сколько бы упоминаний о нём ни было в других местах.

Запись о канале даёт те же три поля, что и остальные: какие claims канал добавляет **сверх
обязательных**, происходит ли подтверждение внутри канала и нужно ли пользователю отправлять
секретное сообщение — или мессенджер сам передаёт код боту. Канал в работе заводится в группах «В разработке»
или «В плане», а не во встроенных.

Собственного адаптера, который остаётся у вас, это не касается: список описывает поставку Veriqa.
Но у своей интеграции стоит завести такую же запись в вашей документации — вопросы к каналу
задают те же.

## Работа по тенантам

Мультитенантность входит в этот SPI, а не вынесена за его границы. Свой канал читает креды, тексты и
лимиты тенанта тем же способом, что и каналы поставки, — через те же публичные части и по одному
правилу: **тенанта называет тот, кто законно его знает, и называет один раз.**

**Набор тенантов даёт `IPollingTenantSource`.** Получите его из контейнера и спросите, для каких
тенантов ваш канал активен; элемент `null` — дефолтный неявный тенант self-hosted-установки. У этого
есть одно предусловие: набор строится из **объявлений** зарегистрированных каналов на уровне ядра, а
`AddChannel` объявления не создаёт — он регистрирует адаптер и его маршрут. Поэтому объявите свой
канал в хосте, рядом с вызовом `AddChannel`:

```csharp Program.cs theme={null}
builder.Services.AddSingleton(sp => new CoreChannelDeclaration(
    "acme-chat",
    isEnabled: () => sp.GetRequiredService<IOptionsMonitor<AcmeChatOptions>>().CurrentValue.Enabled,
    usesPolling: () => sp.GetRequiredService<IOptions<AcmeChatOptions>>().Value.UsePolling));
```

Оба факта читаются делегатами, и каналы поставки замыкают их ровно на эти два аксессора.
`isEnabled` спрашивают заново при каждом построении доступного набора, поэтому он обязан читать
источник, переживающий перечитывание конфигурации: монитор, а не объект настроек, связанный один
раз, — такой заморозил бы правку на стороне развёртывания. `usesPolling` — решение на старте, и
каналы поставки читают его через `IOptions`. Обоим нужна секция в контейнере
(`builder.Services.Configure<AcmeChatOptions>(builder.Configuration.GetSection("AcmeChat"))`) — на
одну строку больше, чем ручное связывание, которое §6 делает для самого адаптера.

Тот же `isEnabled` вводит ваш канал в `channels_enabled` уровня ядра. Без объявления набор тенантов
возвращается пустым и пуллинг не стартует, а проверка старта, о которой говорит замечание в §2,
вашего канала не касается; с объявлением включённый канал, которому причитается значение
`InboundVerification`, роняет хост ровно как канал поставки.

**Тенанта называйте скоупом, вокруг обработки, на своём фоновом пути:**

```csharp theme={null}
foreach (var tenantId in tenants)
{
    // Тенант ИМЕННО ЭТОЙ итерации: всё, что резолвится ниже, — креды, тексты, ключи
    // ограничителя — читает уровни этого тенанта.
    using var scope = ChannelTenantContext.BeginScope(tenantId);

    await ProcessUpdatesAsync(tenantId, cancellationToken);
}
```

Своя проба здоровья работает вне запроса и отчитывается о самой установке, поэтому дефолтного
тенанта она называет **явно** — `ChannelTenantContext.BeginScope(null)`, — а не умолчанием: чтение
кред без единого открытого скоупа неотличимо от пути, забывшего назвать тенанта, и различить их за
вас ниже по стеку некому.

**На вебхуке скоуп не открывайте.** Тенант входящего запроса уже назван — маршрутом
(`/api/channels/{channelType}/webhook/{tenant}`), до вызова вашего адаптера. Свой скоуп, открытый
там, перенацелит все чтения кред ниже него, включая чтения ядра, на выбранного вами тенанта вместо
названного хостом.

**Свои креды вы резолвите, а не получаете.** Объявите свой ключ и резолвите его публичным
`IConfigurationResolver` с `ResolutionContext.ForTenant(tenantId)` — тем же каноничным резолвером и
тем же порядком уровней, которым идут каналы поставки. Публичная фабрика клиентов
`IChannelClientFactory` для этого не нужна: она строит и кеширует *клиента* на пару
`(канал, тенант)`, а это другая работа. Как объявляется свой ключ — каталог, адрес на каждом уровне,
регистрация — описано в [Свои ключи конфигурации](/docs/ru/guides/config-keys).

## HTTP-адаптер канала на любом языке

Всё, что выше, — путь .NET: ваш адаптер работает внутри хоста. Второму пути не нужен ни .NET, ни код
в хосте. **HTTP-канал** — адаптер из поставки Veriqa, который стоит в хосте вместо вашего и передаёт
работу по HTTP **внешнему адаптеру** — сервису, который вы пишете на любом языке и запускаете рядом с
Veriqa или в своём периметре. Канал заводится конфигурацией.

<Note>
  Wire-контракт между Veriqa и внешним адаптером — версии `1.0`, он выходит в статусе **`preview`**.
  Объявление его стабильным — отдельное решение; до него ревизия контракта ещё может его изменить.
</Note>

### Модель транспорта

* **Платформа говорит с вашим адаптером, а не с Veriqa.** Адаптер сам принимает вебхуки платформы
  или поллит её, проверяет их и сам отвечает на challenge-хендшейк платформы. GET-маршрута для
  HTTP-канала Veriqa не маппит.
* **Адаптер постит события в Veriqa** на маршруты, которые есть у каждого канала:
  `/api/channels/{channelType}/webhook` или `/api/channels/{channelType}/webhook/{tenant}`. Событие по
  транзакции уходит на маршрут **тенанта этой транзакции** — того, что пришёл в поле `tenant` вызовов
  `deep-link` и `send-prompt` этой транзакции; `null` — маршрут без сегмента тенанта. Второй раз Veriqa
  тенанта не сообщит: связь «транзакция → тенант» храните у себя.
* **Свой публичный вход адаптер защищает сам.** Rate-limit Veriqa охраняет её собственные маршруты;
  вход, который адаптер открывает платформе, нуждается в своей защите от мусорного трафика.
* **Veriqa вызывает адаптер по одному адресу**, `Http:BaseUrl`, — за всем, что ей нужно от канала.
* **Креды платформы остаются у адаптера.** Veriqa их не запрашивает и не хранит.
* **Одна регистрация — один внешний адаптер**: один адрес, один секрет транспорта, одни факты канала.
  Без `Http:Tenant` она обслуживает всех тенантов установки. Тенант со своей реализацией получает свою
  регистрацию под своим именем (например `shop-a-viber`), и `Http:Tenant` называет этого тенанта.
* **Стандартный образ Veriqa регистрирует HTTP-каналы всегда**: канал добавляется переменными
  окружения и перезапуском контейнера, без пересборки образа. Embedded-хост подключает пакет
  `Veriqa.Core.ChannelAdapter.Http` и добавляет один вызов:

```csharp Program.cs theme={null}
authServer.AddChannelAdapters(adapters => adapters.AddHttpChannels());
```

### Конфигурация

Каждая секция `Veriqa:Channels:{channelType}` с подсекцией `Http` становится одним HTTP-каналом под
именем секции:

```json appsettings.json theme={null}
{
  "Veriqa": {
    "Channels": {
      "acme-chat": {
        "InboundVerification": "signature",
        "Http": {
          "BaseUrl": "https://acme-adapter.internal/",
          "DisplayName": "Acme Chat",
          "Capabilities": {
            "SupportsInChannelConfirmation": true,
            "DeliversOutcomeNotice": true
          }
        }
      }
    }
  }
}
```

Тот же канал переменными окружения, вместе с секретом транспорта — держите его там или в хранилище
секретов, а не в `appsettings.json`. Тип канала с дефисом из shell не экспортировать, но
`environment:` compose-файла и `docker run -e` его принимают:

```bash theme={null}
Veriqa__Channels__acme-chat__InboundVerification=signature
Veriqa__Channels__acme-chat__Http__BaseUrl=https://acme-adapter.internal/
Veriqa__Channels__acme-chat__Http__TransportSecret=whsec_<base64 от 24–64 случайных байт>
Veriqa__Channels__acme-chat__Http__Capabilities__SupportsInChannelConfirmation=true
```

Что важно знать о ключах (полный перечень — в
[справочнике конфигурации](/docs/ru/reference/configuration#ключи-в-каталоге-объявлений)):

* **Факты канала и кнопку задаёт оператор** — ключами `Http:Capabilities:*` (по ключу на свойство
  `ChannelCapabilities`), `Http:DisplayName` и `Http:IconSvgPath`. Сетевого манифеста нет: Veriqa не
  спрашивает адаптер, что он умеет. Незаданный факт получает безопасное умолчание
  `ChannelCapabilities` — все булевы `false`, единственный вид контента `plain_text`, своего правила
  об эмодзи у канала нет (`RendersEmoji` не задан).
* **`Http:Tenant`** называет тенанта-владельца регистрации. Она обслуживает только его; у остальных
  тенантов этого канала нет — ни на входящем маршруте, ни в событиях, ни в исходящих вызовах. Ключ не
  задан — регистрация обслуживает всех тенантов установки.
* **`Http:AdapterSettings`** — непрозрачная строка до 8192 символов, которую Veriqa пересылает
  дословно в каждом вызове и никогда не читает. У неё уровни ядра и тенанта; значение тенанта
  заменяет значение ядра целиком.
* **`Http:AllowInsecureHttp`** разрешает базовый URL со схемой `http`. Только для изолированной
  внутренней сети (соседний контейнер); при старте Veriqa пишет предупреждение.
* **Все значения `Http:*` фиксируются на старте**, кроме `Http:AdapterSettings`, который резолвится на
  каждый вызов. Остальные меняются только перезапуском.
* **Имя канала** — в формате из раздела [Имя канала](#3-имя-канала), а имена каналов поставки Veriqa
  зарезервированы, даже если встроенный канал в хост не подключён: `max`, `telegram`, `whatsapp`,
  `email`. Резерв растёт с выходом новых встроенных каналов.
* **Ошибка конфигурации, найденная на старте, останавливает его** с сообщением, называющим ключ или
  секцию канала, — например: базовый URL не задан или не абсолютный, базовый URL со схемой `http` без
  `Http:AllowInsecureHttp`, не задан `Http:TransportSecret`, секрет не в форме `whsec_`, один секрет у
  двух регистраций, запрещённая схема диплинка, таймаут не в форме `hh:mm:ss`, `Http:AdapterSettings`
  длиннее 8192 символов, зарезервированное имя или имя, которое уже занято другим каналом хоста.
  Пустой `Http:Tenant` — тоже ошибка: чтобы обслуживать всех тенантов, ключ удаляют.

В отличие от канала через `AddChannel` (замечание в §2), у HTTP-канала `InboundVerification`
**проверяется** на старте: регистрация объявляет канал ядру, и включённый HTTP-канал без значения
роняет хост.

### Wire-контракт

Каждый вызов Veriqa — подписанный `POST` JSON-конверта на `Http:BaseUrl`: `version`, `operation`,
`channel_type` (один вход может обслуживать несколько регистраций), `tenant` (или `null`),
`adapter_settings` (или `null`) и `payload`. Ответ — `200` с конвертом из `version` и ровно одного из
двух: `result` или `error` (`code` и `message`).

| Операция | Что делает адаптер |
| - | - |
| `send-prompt` | Отправляет вопрос подтверждения с двумя кнопками. Veriqa передаёт отрендеренный ею текст, подписи кнопок и структурный контекст — рендерить можно и самому |
| `send-message` | Отправляет пользователю простое сообщение |
| `report-outcome` | Показывает пользователю итог транзакции; вызывается, только если канал объявил `DeliversOutcomeNotice` |
| `deep-link` | Возвращает `url`, который ведёт пользователя в ваш канал по транзакции |
| `health` | Сообщает, здоров ли адаптер, его версию и наибольшую поддерживаемую версию провода |

| Событие | Когда адаптер его постит |
| - | - |
| `auth_start` | Пользователь пришёл в канал по диплинку транзакции |
| `auth_confirm` | Пользователь нажал кнопку подтверждения |
| `auth_decline` | Пользователь нажал кнопку отказа |
| `phone_shared` | Пользователь поделился номером телефона |
| `unaddressed` | Личное сообщение от известного отправителя без транзакции |
| `unrelated` | Всё, на что Veriqa отвечать незачем |

Поля каждого `payload`, результата и события — в машиночитаемой
[JSON-схеме провода](#conformance-набор); гайд их не повторяет. Правила, которых схема не выражает:

* **Коды ошибок.** Коды, которые называет провод, — в схеме, и любой другой код тоже принимается. Код
  `error` пишется в лог и больше ничего не решает, за одним исключением: `channel_cannot_continue` в
  ответе на `send-prompt` сразу завершает транзакцию отказом. Любой другой `error` действует по своей
  операции, и после него вызов не повторяется:
  * `send-prompt` — транзакция остаётся открытой и ждёт нового `auth_start` пользователя или истечения
    срока;
  * `deep-link` — у пользователя нет входа в ваш канал: страница входа рисуется без него;
  * `send-message`, `report-outcome` — отказ пишется в лог и учитывается в метриках, и только.
* **`deep-link` идемпотентен**: повторный вызов по той же транзакции возвращает тот же `url` и не
  создаёт нового состояния.
* **У ответа есть границы.** Ответ читается не более 1 МБ и сверх лимита отвергается, редиректы не
  выполняются, диплинк принимается только абсолютным URI со схемой `https` или из
  `Http:DeepLinkSchemes`, не длиннее 2048 символов.
* **Таймауты и повторы.** Попытка ждёт `Http:OperationTimeout` (по умолчанию 10 с); `deep-link` —
  `Http:DeepLinkTimeout` (2 с), потому что стоит на пути страницы входа. `send-prompt`, `send-message`
  и `report-outcome` повторяются при транспортном отказе — нет соединения, таймаут, `5xx`, `429` — до
  трёх раз, все попытки в пределах `Http:RetryBudget` (20 с). Больше не повторяется ничего: `200` с
  `error`, `3xx`, прочие `4xx`, слишком большой или некорректный ответ. `deep-link` и `health` —
  одна попытка.
* **Отвергайте конверт события на входе вызовов.** Тело с `event` вместо `operation`, пришедшее на
  `Http:BaseUrl`, — ваше же событие, отражённое обратно; ответ на него — что угодно, кроме `200` с
  `result`.
* **Обслуживайте вызовы, пока открыт свой запрос.** На `auth_start` Veriqa шлёт адаптеру
  `send-prompt` **до** того, как ответит на ваш `POST` события. Адаптер, который не умеет принимать
  вызовы параллельно со своими запросами в Veriqa, получает таймаут вопроса, и транзакция ждёт
  истечения срока.

### Подпись

Оба направления подписываются секретом транспорта регистрации по схеме
[Standard Webhooks](https://www.standardwebhooks.com/) `v1` (HMAC-SHA256) — библиотека для неё есть в
любом языке. Заголовки — `webhook-id`, `webhook-timestamp` и `webhook-signature`; метка времени
принимается в пределах 5 минут в обе стороны от часов получателя.

* **Ротация без простоя.** Новый секрет — в `Http:TransportSecret`, старый — в
  `Http:PreviousTransportSecret`: отправитель ставит обе подписи, получатель принимает любую. Пока
  предыдущий секрет задан, Veriqa пишет при старте предупреждение.
* **Ответы не подписываются**: их подлинность держится на соединении, которое Veriqa открыла по
  настроенному адресу, — поэтому по умолчанию требуется `https`.
* **Неверная подпись — `403`**, без конверта и без какого-либо эффекта, в обе стороны: так Veriqa
  отвечает на ваше событие без подписи или с неверной подписью, и так же обязан отвечать на такой вызов
  ваш адаптер.
* **У каждой регистрации свой секрет.** Общий секрет позволил бы одной регистрации выдать себя за
  другую, поэтому хост не стартует.

### Идемпотентность

* **`webhook-id` вызовов `send-prompt`, `send-message` и `report-outcome` — ключ идемпотентности.**
  Повтор с ключом, который вы уже успешно обслужили, ничего нового не отправляет и возвращает результат
  первого вызова — ту же ссылку на сообщение. У `deep-link` и `health` `webhook-id` уникален, но ключом
  не является.
* **`webhook-id` события выбираете вы**: уникальный на событие и неизменный при повторной доставке того
  же события. Из него Veriqa строит ключи вызванных событием `send-prompt` и `report-outcome`, поэтому
  повторно доставленное событие не задаёт вопрос дважды.
* **Неуспешная попытка ключ не связывает**: следующая попытка с ним обслуживается заново.
* **Срок хранения выбирает оператор** адаптера, с двумя нижними границами: не меньше бюджета повторов
  Veriqa (`Http:RetryBudget`), а для ключа, рождённого событием, — ещё и не меньше окна, в котором сам
  адаптер может доставить это событие повторно.

### Версия провода и статус `preview`

Каждый конверт в обе стороны несёт `version` — версию провода отправителя, `major.minor`; первая —
`1.0`, в статусе `preview`. В пределах мажорной версии изменения только аддитивны — новое
необязательное поле, операция, значение перечисления или тип события, — и у каждого объявлено поведение
получателя, который его не знает: незнакомое поле игнорируется, на незнакомую операцию — ответ
`unsupported_operation`, незнакомый вариант вопроса показывается как вопрос без деталей, незнакомое
событие Veriqa считает `unrelated`. Удаление или переименование поля, смена его смысла — только новой
мажорной версией. На вызов другой мажорной версии отвечайте `unsupported_wire_version`; Veriqa, в свою
очередь, событие другой мажорной версии не обрабатывает, а такой ответ считает ответом вне контракта.
Каждое событие несёт и `adapter_version` — версию вашего адаптера в semver.

### Conformance-набор

Veriqa поставляет исполняемый conformance-набор, который проверяет адаптер на соответствие проводу,
на каком бы языке тот ни был написан. Он проверяет три вещи:

* **вызовы через настоящую обёртку** — каждую операцию, повтор с тем же ключом, неверный секрет и метку
  времени вне окна. Эти вызовы делает тот самый код HTTP-канала, что работает в Veriqa, поэтому набор
  не может разойтись с Veriqa;
* **некорректные вызовы**, которых Veriqa не делает, — без подписи, с незнакомыми полем, вариантом или
  операцией, чужой мажорной версии, конверт события, отражённый на вход вызовов, — их шлёт отдельный
  отправитель;
* **приёмник событий** — он играет Veriqa для ваших событий: проверяет подпись и схему каждого,
  вызывает `send-prompt` до ответа на `auth_start` и проверяет, что каждое событие пришло на маршрут
  тенанта своей транзакции.

Набор — контейнерный образ, собирается из корня репозитория:

```bash theme={null}
docker build -f src/core/Veriqa.Core.ChannelAdapter.Http.Conformance/Dockerfile -t veriqa-http-channel-conformance .
```

Запускайте его рядом с адаптером, с конфигурацией канала теми же ключами `Veriqa__Channels__<type>__*`,
что читает Veriqa, и собственными ключами под `Conformance__*`: обязательны `ChannelType` и
`ChannelUserId`; `Mode`, `Tenants` и `ReportPath` — по желанию.

| `Conformance__Mode` | Что выполняется | Для чего |
| - | - | - |
| `calls` (по умолчанию) | только вызовы | CI-гейт вашего адаптера |
| `interactive` | вызовы и приёмник; диплинк на платформе вы открываете и кнопки жмёте сами | ручная проверка половины событий |
| `automatic` | вызовы, приёмник и заглушка платформы, играющая пользователя; обязателен `Conformance__PlatformStubUrl` | адаптеры, у которых такая заглушка есть; её контракт — в README набора |

В `interactive` и `automatic` набор стоит на месте Veriqa: слушает порт `8080` образа на тех же путях,
`/api/channels/{type}/webhook` и `/api/channels/{type}/webhook/{tenant}`, поэтому адаптер меняет только
базовый адрес Veriqa, на который постит. Сколько ждать события — `Conformance__EventTimeoutSeconds`.

Набор печатает по строке на сценарий — `PASS <id>`, `FAIL <id>: <причина>` или `N/A <id>: <причина>`, —
пишет JSON-отчёт в `Conformance__ReportPath` и завершается кодом `0` (ни один сценарий не упал), `1`
(упал хотя бы один) или `2` (прогон провести не удалось; сообщение называет, что исправить). Полный
перечень сценариев — в README `src/core/Veriqa.Core.ChannelAdapter.Http.Conformance`.

Машиночитаемая схема провода (JSON Schema) поставляется вместе с набором: в образе —
`/schema/http-channel-wire-v1.schema.json`, в репозитории —
`src/core/Veriqa.Core.ChannelAdapter.Http.Conformance/Schema/`.

### Референсные адаптеры

Три внешних адаптера в репозитории показывают весь контракт на настоящей платформе:

* `samples/python/custom-channel-telegram/` — Telegram на Python;
* `samples/node/custom-channel-whatsapp-twilio/` — WhatsApp через Twilio на Node;
* `samples/node/custom-channel-whatsapp-baileys/` — WhatsApp через Baileys на Node, рабочий; для отладки, небольших команд и собственного использования, номер могут заблокировать.

Каждый запускается рядом с Veriqa из своей папки:

```bash theme={null}
cp .env.example .env    # заполните секрет транспорта и креды платформы
docker compose up
```

Вместе они показывают все операции и все события своего канала, подпись в обе стороны,
идемпотентность (повтор не шлёт второго сообщения), креды по тенанту, `adapter_settings` как
собственную настройку адаптера и защиту собственного публичного входа адаптера. Адаптер Twilio
вдобавок отвечает `channel_cannot_continue`, когда платформа не может доставить вопрос; адаптер Telegram
делит апдейты между Veriqa и вашим собственным ботом.
Адаптер Baileys рабочий и предназначен для отладки сценариев с WhatsApp, небольших команд и собственного
использования: Baileys — неофициальный клиент WhatsApp Web на вашем собственном номере, и WhatsApp может
этот номер заблокировать. Вопрос он
задаёт опросом, а сообщения забирает своей исходящей сессией. README каждого описывает его конфигурацию,
значение `InboundVerification` и ручной живой прогон.

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

### `InboundVerification` внешнего адаптера

Правило раздела [Объявите уровень проверки своего канала — и задокументируйте его](#объявите-уровень-проверки-своего-канала-—-и-задокументируйте-его)
действует для внешнего адаптера без изменений: трафик платформы проверяет ваш адаптер, поэтому
**значение для каждого режима приёма своего канала называете вы** в своей документации, а оператор
объявляет его в секции канала. Умолчания нет. Подпись транспорта между адаптером и Veriqa проверяется
всегда, и это значение её не описывает.

Референсные адаптеры называют свои:

| Адаптер | Режим приёма | `InboundVerification` |
| - | - | - |
| Telegram | `webhook` — секретный токен в заголовке каждого апдейта | `shared_secret` |
| Telegram | `polling` — адаптер сам забирает апдейты | `outbound_fetch` |
| WhatsApp через Twilio | вебхук, подписанный Twilio | `signature` |
| WhatsApp через Baileys | собственная исходящая сессия WhatsApp Web адаптера | `outbound_fetch` |

В той же документации перечислите ключи верхнего уровня своего `raw_metadata`, несущие персональные
данные, включая нативный ID пользователя платформы: по этому перечню интегратор решает, что ему можно
хранить.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.