> ## 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.

# API серверного подтверждения

> Объявить action type, создать подтверждение с бэкенда, показать его QR, прочитать исход и узнать, кто подтвердил.

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

Запускаемый пример — `samples/dotnet/inproc/confirmation` в репозитории: одно приложение, которое
одновременно и издатель Veriqa, и бэкенд, который к нему обращается. Ещё два устроены так же:
`samples/dotnet/inproc/step-up` подтверждает разрушительное действие и проверяет, что подтвердил
владелец аккаунта (`expected_identities`), а `samples/dotnet/inproc/agent-approval` заставляет вызов
инструмента ИИ-агента ждать одобрения человека.

## Как это идёт

1. Бэкенд получает токен грантом **Client Credentials**.
2. Создаёт транзакцию: `POST /api/transaction/confirmation` с **объявленным action type** и значениями
   слотов его сообщения.
3. Показывает пользователю `channel_entry` из ответа — картинку QR (`qr`) на экране или ссылку (`url`)
   на том же устройстве.
4. Пользователь открывает канал, читает текстовку и подтверждает или отклоняет.
5. Бэкенд опрашивает `GET /api/transaction/{id}/result`, пока исход не перестанет быть `pending`.
6. По желанию обменивает подтверждённую транзакцию на `id_token` того, кто подтвердил.

## 1. Настройте клиента

Всё живёт в **записи клиента** — приложения, которое вызывает API:

```json theme={null}
{
  "Veriqa": {
    "OpenIddict": {
      "Clients": [
        {
          "ClientId": "payments-backend",
          "ClientSecret": "<из хранилища секретов>",
          "AllowClientCredentials": true,
          "AllowConfirmationTokenGrant": true,
          "AllowedScopes": [ "openid", "channel" ],
          "MessageTemplates": {
            "approve-payment": {
              "Contract": {
                "Slots": [
                  { "Name": "amount", "Type": "string", "MaxLength": 32, "Required": true },
                  { "Name": "payee", "Type": "string", "MaxLength": 64 }
                ]
              },
              "Templates": [
                "Approve a payment of {amount} to {payee}?",
                "Approve a payment of {amount}?"
              ]
            }
          }
        }
      ]
    }
  }
}
```

| Ключ | Зачем нужен |
| - | - |
| `ClientSecret` | Клиент обязан быть конфиденциальным: `AllowClientCredentials` у клиента без секрета останавливает старт |
| `AllowClientCredentials` | Позволяет бэкенду получить токен для этого API. По умолчанию выключен |
| `AllowConfirmationTokenGrant` | Нужен только для шага 6. Требует `openid` в `AllowedScopes` |
| `MessageTemplates:{action type}` | **Объявление** action type: его контракт и текстовки |

### Где объявляется action type

Action type — **это и есть вид сообщения** (kind), и объявляется он **в записи клиента**:
`Veriqa:OpenIddict:Clients:[n]:MessageTemplates:{action type}:Contract` и `…:Templates`. Групп `ByType`
и `ByAction` для этого добавлять не нужно.

<Warning>
  **Секция хоста `Veriqa:MessageTemplates` action type не объявляет.** Эта секция — уровень `Core`, место
  собственных сообщений продукта, и action type, видимый только там, получает отказ
  `action_type_unknown`: приложение не должно иметь возможность попросить человека подтвердить текст,
  который продукт поставляет для своих нужд. Хост при этом стартует без жалоб — отказ приходит от API.
</Warning>

Читается и уровень `Tenant`, если ваш хост зарегистрировал для него читателя; из коробки такого нет.

### Что пользователь читает по завершении

Конец транзакции описывают две настройки уровня хоста, и рабочие примеры выше выставляют обе:

```json theme={null}
{
  "Veriqa": {
    "Channels": {
      "OutcomeNotice": {
        "DisplayIntent": "NewMessage"
      }
    },
    "MessageTemplates": {
      "outcome-receipt-confirmed": {
        "Contract": {
          "Slots": [ { "Name": "app", "Type": "string", "Source": "ServerSystem" } ]
        },
        "Templates": [ "{app}: confirmed ✅", "Confirmed ✅" ]
      },
      "outcome-receipt-declined": {
        "Contract": {
          "Slots": [ { "Name": "app", "Type": "string", "Source": "ServerSystem" } ]
        },
        "Templates": [ "{app}: declined ❌", "Declined ❌" ]
      }
    }
  }
}
```

`DisplayIntent` задаёт, где появляется квитанция исхода. Продукт везёт `ReplacePrompt` — квитанция
встаёт на место сообщения с вопросом, — и подтверждение как раз тот случай, когда нужно другое
значение: вопрос несёт формулировку действия, и подмена оставляет переписку с исходом, но без
записи о том, на что он отвечал. `NewMessage` сохраняет оба сообщения. Это намерение, а не обещание:
Telegram и MAX его исполняют, WhatsApp всегда шлёт новое сообщение. Там, где платформа позволяет
убрать кнопки у отправленного сообщения, не переписывая его текст, `NewMessage` снимает их тем же
ходом, которым доставляет квитанцию: вопрос сохраняет формулировку как запись о том, на что отвечал
исход, и под завершённой транзакцией не остаётся ничего, что приглашало бы нажать ещё раз. Где такой
операции у платформы нет, кнопка может уцелеть — повторное нажатие приносит лишь ещё одну квитанцию,
потому что терминальную транзакцию не переиграть.

Виды квитанций — собственные сообщения продукта, поэтому живут в секции хоста, а не в записи
клиента: правило про типы действий выше на них не распространяется. Под `ByType:confirmation`
продукт не везёт ничего, поэтому отвечает широкое объявление `{kind}` и переформулировка работает
как написана. `{app}` сервер заполняет `ClientId`, как и выше; последний шаг каждой лестницы не
пользуется слотами, потому что ни один слот квитанции не гарантирован — даже помеченный
`Guaranteed`.

### Контракт

`Contract:Slots` — массив. Каждый слот:

| Поле | Значение |
| - | - |
| `Name` | Имя слота, `snake_case`, уникально в контракте. Шаблон ссылается на него как `{name}` |
| `Type` | `string`, `enum`, `number`, `datetime` или `entity_ref` |
| `Source` | Не указан — `Caller`: значение передаёт ваш бэкенд в `slot_values`. `ServerSystem` — значение подставляет сервер; для подтверждения это `app` |
| `Required` | Слоты вызывающей стороны: без значения запрос отклоняется. По умолчанию `false` |
| `Guaranteed` | Серверные слоты: значение есть всегда. По умолчанию `false` |
| `MaxLength` | **Обязателен** для слота вызывающей стороны типа `string` / `entity_ref`. Потолок — 128 |
| `Values` | Допустимый набор слота `enum`, непустой |
| `Min` / `Max` | Необязательные границы слота `number` |

`{app}` подтверждения сервер заполняет `ClientId` клиента, а не его `DisplayName`. Чтобы в текстовке было
читаемое имя, заведите свой слот.

### Шаблоны

`Templates` — лестница, от самой полной текстовки к минимальной. Показывается первая ступень, у которой
заполнены все слоты, поэтому необязательный `payee` аккуратно выпадает, если его не передали.
**Последняя ступень может опираться только на слоты, у которых значение есть всегда**, — слот
вызывающей стороны с `Required: true` или серверный слот с `Guaranteed: true`. Лестница, чья последняя
ступень опирается на необязательный слот, отбрасывается целиком, и транзакции нечего показать.
Мессенджеры показывают простой текст; ступень может быть и структурой `{ "Plain": "…", "Html": "…" }` —
см. [`Veriqa:MessageTemplates`](/docs/ru/reference/configuration#veriqamessagetemplates).

## 2. Получите токен

```http theme={null}
POST /connect/token
Content-Type: application/x-www-form-urlencoded
Authorization: Basic base64(client_id:client_secret)

grant_type=client_credentials
```

В ответе — `access_token`, `token_type` и `expires_in`; кешируйте токен почти до истечения. Токен,
выданный пользователю, этот API отклоняет с `403`.

Эндпоинт отдаётся только по `https`: по обычному HTTP он отвечает `400 invalid_request`, «This server
only accepts HTTPS requests». За reverse-proxy, который терминирует TLS, proxy должен быть доверенным
и передавать `X-Forwarded-Proto` ([служба ОС, шаг 7](/docs/ru/quickstart/os-service#7-https)).

## 3. Создайте подтверждение

```http theme={null}
POST /api/transaction/confirmation
Authorization: Bearer <access_token>
Content-Type: application/json

{
  "action_type": "approve-payment",
  "slot_values": { "amount": "42.00 EUR", "payee": "ACME Ltd" },
  "idempotency_key": "9f1c2e4a-payment-1842"
}
```

| Поле | Тип | Значение |
| - | - | - |
| `action_type` | строка, **обязательно** | Объявленный action type (см. выше) |
| `slot_values` | объект `имя → строка` | Значения слотов вызывающей стороны. Числа и даты тоже передаются строками. Слот, которого нет в контракте, отклоняется. Общий размер — до 16 КБ |
| `idempotency_key` | строка, до 128 | Повтор с тем же ключом от того же клиента отвечает **той же** транзакцией со свежим `channel_entry`, а не создаёт вторую |
| `locale` | строка, BCP 47 | Язык текстовки и дат в ней. Не указан — базовый язык |
| `time_zone` | строка, IANA | Зона, в которой показываются моменты текстовки. Не указана — умолчание установки, иначе UTC |
| `ttl_seconds` | целое | Время жизни транзакции, приводится к 60…1800. Не указано — `Veriqa:TransactionEngine:TransactionTtlSeconds` |
| `ui_config` | строка | Запись `ui_config`, разрешённая этому клиенту. Не указана — запись клиента по умолчанию |
| `requested_channel_type` | строка | Предпочтительный канал, например `telegram` |
| `allowed_channel_types` | массив строк | Каналы, через которые может пройти подтверждение |
| `expected_identities` | объект `тип → значение` | Кого ожидаете в роли подтверждающего — по сравнимым типам идентичности, объявленным в [`IdentityMatch.ComparableTypes`](/docs/ru/reference/configuration#ключи-в-каталоге-объявлений). Тогда результат скажет, какой тип совпал. Не передавайте, если хотите узнать, кто подтвердит |
| `include_channel_entries` | boolean | Вернуть ещё и `channel_entries` — deep link с QR для каждого доступного канала |
| `correlation_id` | строка | Ваш идентификатор для ваших логов |
| `client_context` | строка | Ваши непрозрачные данные, до 4 КБ |

Сама текстовка не возвращается, а значения слотов не пишутся в лог выше `Debug`.

### Ответ — `201 Created`

```json theme={null}
{
  "transaction_id": "k3VfQx0nS9e0mT2cHq7L1A",
  "expires_at": "2026-09-16T15:45:00+00:00",
  "response_valid_until": "2026-09-16T15:45:00+00:00",
  "channel_entry": {
    "kind": "deep_link",
    "channel_type": "telegram",
    "display_name": "Telegram",
    "url": "https://t.me/your_bot?start=auth_…",
    "qr": "data:image/png;base64,iVBORw0KGgo…",
    "valid_until": "2026-09-16T15:45:00+00:00"
  }
}
```

*Идентификаторы и ссылка синтетические.* Ответ отдаётся с `Cache-Control: no-store`.

| Поле | Значение |
| - | - |
| `transaction_id` | Идентификатор для результата и обмена на токен |
| `expires_at` | Когда транзакция истекает |
| `response_valid_until` | Самый ранний момент среди транзакции и всех возвращённых входов: после него ничего из этого ответа не показывайте |
| `channel_entry.kind` | `deep_link` — ссылка прямо в канал (доступен один канал); `page_url` — страница Veriqa, где пользователь выбирает канал |
| `channel_entry.channel_type` | Канал для `deep_link`; `null` для `page_url` |
| `channel_entry.display_name` | Название канала для подписи входа — то же, что на вкладке канала на странице входа Veriqa, включая метку, объявленную собственным каналом. Не локализуется; `null` для `page_url`. В QR-картинке его нет: подпишите вход в своём интерфейсе |
| `channel_entry.url` | Адрес, который открывают на том же устройстве |
| `channel_entry.qr` | Вход как PNG `data:` URI — вставляйте в `<img src>` как есть. Без [hop-режима](/docs/ru/reference/configuration#veriqahopmode) в нём `url`. При включённом hop-режиме — одноразовая ссылка на ваш сервер Veriqa, ведущая туда же: в канал для `deep_link`; для `page_url` при `HopMode.QrMode = Unified` — на страницу выбора канала на телефоне. Под кодом — полоса атрибуции, поэтому картинка не квадратная |
| `channel_entry.valid_until` | После этого момента вход не показывайте |
| `channel_entries` | Только при `include_channel_entries: true`: массив входов той же формы, по одному на канал |

<Warning>
  **Вход — на предъявителя.** Кто откроет его первым, тот и станет подтверждающим. Показывайте QR только
  тому, кого спрашиваете, а контекст, по которому человек узнает запрос, кладите в текстовку.
</Warning>

## 4. Прочитайте исход

```http theme={null}
GET /api/transaction/{transaction_id}/result
Authorization: Bearer <access_token>
```

```json theme={null}
{ "outcome": "confirmed", "matched_type": null }
```

| `outcome` | Значение |
| - | - |
| `pending` | Ответа ещё нет — спросите снова через пару секунд |
| `confirmed` | Человек подтвердил |
| `declined` | Человек отклонил |
| `expired` | Время вышло |
| `failed` | Транзакция завершилась без решения человека, например канал не смог продолжить |

`matched_type` — имя сравнимого типа, совпавшего с `expected_identities`, или `null`. Читать результат
может только клиент, создавший транзакцию: любая другая — неизвестная, удалённая после
`CompletedRetentionSeconds` или чужая — отвечает одинаково, `404 transaction_not_found`.

## 5. Узнайте, кто подтвердил (необязательно)

С `AllowConfirmationTokenGrant` подтверждённую транзакцию можно **один раз** обменять на `id_token`
подтвердившего:

```http theme={null}
POST /connect/token
Content-Type: application/x-www-form-urlencoded
Authorization: Basic base64(client_id:client_secret)

grant_type=urn:veriqa:params:oauth:grant-type:confirmation
&transaction_id=k3VfQx0nS9e0mT2cHq7L1A
&scope=openid%20channel
```

`id_token` несёт `sub`, а со scope `channel` — ещё `channel_type` и `channel_user_id`. Правила обмена —
однократное погашение, `invalid_grant` на любое «нет», что узнаёт приложение — в
[привязке с вашего сервера](/docs/ru/scenarios/channel-linking#привязка-с-сервера-без-входа).

## Ошибки запроса создания

Любой отказ — Problem Details (`application/problem+json`): код в `title`, а `detail` не называет ни
одного переданного вами значения. При любом отказе ничего не создаётся.

| Статус | `title` | Что проверить |
| - | - | - |
| `401` | — | Токена нет или он недействителен |
| `403` | — | Токен выдан пользователю, а не клиенту |
| `400` | `action_type_missing` | `action_type` не передан или пуст |
| `400` | `action_type_unknown` | Объявление этого action type для этого клиента не видно: его нет в `MessageTemplates` записи клиента, или оно лежит только в секции хоста `Veriqa:MessageTemplates`. Лог сервера называет адрес, по которому искал |
| `400` | `ui_config_invalid` | Записи `ui_config` нет или она не разрешена этому клиенту |
| `400` | `locale_invalid` | `locale` — не корректный тег BCP 47 |
| `400` | `time_zone_invalid` | `time_zone` — не известная серверу зона |
| `400` | `slot_undeclared` | В `slot_values` есть имя, которого нет в контракте |
| `400` | `slot_required_missing` | У обязательного слота нет значения |
| `400` | `slot_value_invalid` | Значение нарушает тип слота, `MaxLength`, `Values` или границы |
| `400` | `slot_values_too_large` | `slot_values` больше 16 КБ |
| `400` | `candidate_type_undeclared` / `candidate_value_invalid` | Тип из `expected_identities` не объявлен для этого клиента, или у значения нет канонической формы |
| `400` | `invalid_idempotency_key` / `idempotency_key_conflict` | Ключ длиннее 128 или уже использован с другими параметрами |
| `503` | `channel_display_failed` | Транзакция создана, но вход сейчас построить не удалось — например, не включён ни один канал. Повтор с тем же `idempotency_key` ответит ею же |

## Дальше

<CardGroup cols={2}>
  <Card title="Привязка с вашего сервера" icon="link" href="/docs/ru/scenarios/channel-linking#привязка-с-сервера-без-входа">
    Узнайте, какой аккаунт мессенджера подтвердил, и сохраните его рядом с пользователем.
  </Card>

  <Card title="Veriqa:MessageTemplates" icon="message" href="/docs/ru/reference/configuration#veriqamessagetemplates">
    Лестницы, редакции, уровни и оси сообщений.
  </Card>
</CardGroup>


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