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

# Server-to-server confirmation API

> Declare an action type, create a confirmation from your backend, show its QR, read the outcome and learn who confirmed.

Your backend can ask a person to confirm an action — a payment, a device, a data transfer — in a
trusted channel, with no sign-in and no browser redirect. This page is the full contract: the
configuration that makes it possible, the requests and the answers.

A runnable example is `samples/dotnet/inproc/confirmation` in the repository: one application that
is both the Veriqa issuer and the backend that calls it. Two more are built the same way:
`samples/dotnet/inproc/step-up` confirms a destructive action and checks that the account owner
confirmed it (`expected_identities`), and `samples/dotnet/inproc/agent-approval` makes an AI agent's
tool call wait for a person's approval.

## The flow

1. Your backend gets a token with the **Client Credentials** grant.
2. It creates the transaction: `POST /api/transaction/confirmation`, naming a **declared action type**
   and filling the slots of its message.
3. It shows the user `channel_entry` from the answer — the QR image (`qr`) on a screen, or the link
   (`url`) on the same device.
4. The user opens the channel, reads the wording and confirms or declines.
5. Your backend polls `GET /api/transaction/{id}/result` until the outcome is no longer `pending`.
6. Optionally, it exchanges a confirmed transaction for the `id_token` of the person who confirmed.

## 1. Configure the client

Everything lives in **the client entry** of the application that calls the API:

```json theme={null}
{
  "Veriqa": {
    "OpenIddict": {
      "Clients": [
        {
          "ClientId": "payments-backend",
          "ClientSecret": "<from a secret store>",
          "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}?"
              ]
            }
          }
        }
      ]
    }
  }
}
```

| Key | Why it is needed |
| - | - |
| `ClientSecret` | The client must be confidential: `AllowClientCredentials` on a client without a secret stops the start |
| `AllowClientCredentials` | Lets the backend get a token for this API. Off by default |
| `AllowConfirmationTokenGrant` | Only for step 6. Requires `openid` in `AllowedScopes` |
| `MessageTemplates:{action type}` | The **declaration** of the action type: its contract and its wordings |

### Where the action type is declared

The action type **is the message kind**, and it is declared **in the client entry**:
`Veriqa:OpenIddict:Clients:[n]:MessageTemplates:{action type}:Contract` and `…:Templates`. There is
no `ByType` or `ByAction` group to add for it.

<Warning>
  **The host section `Veriqa:MessageTemplates` does not declare an action type.** That section is the
  `Core` level, the place of the product's own messages, and an action type visible only there is
  refused with `action_type_unknown`: an application must not be able to ask a person to confirm a
  text the product ships for its own purposes. The host starts without complaint either way — the
  refusal comes from the API.
</Warning>

A `Tenant` level is also read, when your host registers a reader for it; none is registered out of
the box.

### What the user reads when it ends

Two host-level settings word the end of the transaction, and the runnable samples above state both:

```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` says where the receipt of the outcome appears. The product ships `ReplacePrompt` —
the receipt takes the place of the message that asked the question — and a confirmation is the case
for the other value: the question carries the wording of the action, so replacing it leaves the
conversation with an outcome and no record of what it answered. `NewMessage` keeps both. It is an
intent rather than a promise: Telegram and MAX honour it, WhatsApp always sends a new message. Where
the platform lets the buttons of a delivered message be taken away without rewriting its text,
`NewMessage` takes them away as it delivers the receipt: the question keeps its wording as the record
of what the outcome answered, and nothing under a settled transaction invites another press. Where
the platform has no such operation, a button may survive — pressing again only earns another receipt,
because a terminal transaction cannot be replayed.

The receipt kinds are the product's own messages, so they live in the host section rather than in
the client entry — the rule about action types above does not reach them. The product ships no
wording under `ByType:confirmation`, so the broad `{kind}` declaration is the step that answers and
rewording it works as written. `{app}` is filled by the server with the `ClientId`, as above; the
last step of each ladder uses no slot, because no slot of a receipt is guaranteed — not even one
marked `Guaranteed`.

### The contract

`Contract:Slots` is an array. Each slot:

| Member | Meaning |
| - | - |
| `Name` | Slot name, `snake_case`, unique within the contract. The template refers to it as `{name}` |
| `Type` | `string`, `enum`, `number`, `datetime` or `entity_ref` |
| `Source` | Omitted — `Caller`: your backend fills it in `slot_values`. `ServerSystem` — the server fills it; for a confirmation that is `app` |
| `Required` | Caller slots: the request is refused without a value. Default `false` |
| `Guaranteed` | Server slots: the value is always there. Default `false` |
| `MaxLength` | **Mandatory** for a caller `string` / `entity_ref` slot. Ceiling 128 |
| `Values` | The allowed set of an `enum` slot, non-empty |
| `Min` / `Max` | Optional bounds of a `number` slot |

The server fills `{app}` of a confirmation with the client's `ClientId`, not its `DisplayName`. To put a
human-readable name into the wording, use a slot of your own.

### The templates

`Templates` is a ladder, fullest wording first. The shown step is the first one whose slots all have
values, so the optional `payee` drops out cleanly when it is not sent. **The last step may use only
slots that always have a value** — a caller slot with `Required: true` or a server slot with
`Guaranteed: true`. A ladder whose last step leans on an optional slot is left behind whole, and the
transaction has no text to show. Messengers show the plain text; a step may also be a structure
`{ "Plain": "…", "Html": "…" }` — see [`Veriqa:MessageTemplates`](/docs/reference/configuration#veriqamessagetemplates).

## 2. Get a token

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

grant_type=client_credentials
```

The answer carries `access_token`, `token_type` and `expires_in`; cache the token until shortly before
it expires. A token issued to a user is refused on this API with `403`.

The endpoint is served over `https` only — over plain HTTP it answers `400 invalid_request`, "This
server only accepts HTTPS requests". Behind a reverse proxy that terminates TLS, the proxy must be
trusted and forward `X-Forwarded-Proto`
([OS service, step 7](/docs/quickstart/os-service#7-https)).

## 3. Create the confirmation

```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"
}
```

| Field | Type | Meaning |
| - | - | - |
| `action_type` | string, **required** | A declared action type (see above) |
| `slot_values` | object `name → string` | Values of the caller slots. Numbers and dates are sent as strings too. A slot the contract does not declare is refused. Total size up to 16 KB |
| `idempotency_key` | string, up to 128 | A repeat with the same key from the same client answers with the **same** transaction and a fresh `channel_entry`, instead of creating a second one |
| `locale` | string, BCP 47 | Language of the wording and of the dates in it. Absent — the base language |
| `time_zone` | string, IANA | Zone the moments of the wording are shown in. Absent — the deployment default, else UTC |
| `ttl_seconds` | integer | Lifetime of the transaction, clamped to 60…1800. Absent — `Veriqa:TransactionEngine:TransactionTtlSeconds` |
| `ui_config` | string | A `ui_config` record allowed to this client. Absent — the client's default |
| `requested_channel_type` | string | Preferred channel, for example `telegram` |
| `allowed_channel_types` | string array | Channels the confirmation may go through |
| `expected_identities` | object `type → value` | Who is expected to confirm, by the comparable identity types declared in [`IdentityMatch.ComparableTypes`](/docs/reference/configuration#keys-in-the-declaration-catalog). The result then says which type matched. Leave it out when you want to learn who confirmed |
| `include_channel_entries` | boolean | Also return `channel_entries` — a deep link with its QR for every available channel |
| `correlation_id` | string | Your identifier, for your own logs |
| `client_context` | string | Opaque data of yours, up to 4 KB |

The wording itself is never returned, and your slot values are not logged above `Debug`.

### The answer — `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"
  }
}
```

*The identifiers and the link are synthetic.* The answer is sent with `Cache-Control: no-store`.

| Field | Meaning |
| - | - |
| `transaction_id` | The identifier for the result and the token exchange |
| `expires_at` | When the transaction expires |
| `response_valid_until` | The earliest moment among the transaction and every entry returned: do not show anything from this answer after it |
| `channel_entry.kind` | `deep_link` — a link straight into the channel (one channel is available); `page_url` — Veriqa's page where the user picks a channel |
| `channel_entry.channel_type` | The channel of a `deep_link`; `null` for `page_url` |
| `channel_entry.display_name` | The channel name to label the entry with — the one the Veriqa sign-in page shows on the channel tab, a custom channel's declared label included. Not localized; `null` for `page_url`. The QR image does not carry it: label the entry in your own UI |
| `channel_entry.url` | The address to open on the same device |
| `channel_entry.qr` | The entry as a PNG `data:` URI — put it into `<img src>` as is. Without [hop mode](/docs/reference/configuration#veriqahopmode) it encodes `url`. With hop mode on it encodes a one-time link to your Veriqa server leading to the same place: into the channel for `deep_link`; for `page_url` with `HopMode.QrMode = Unified`, to the page where the user picks a channel on the phone. The image carries the attribution strip under the code, so it is not square |
| `channel_entry.valid_until` | Do not show the entry after this moment |
| `channel_entries` | Only with `include_channel_entries: true`: an array of entries of the same shape, one per channel |

<Warning>
  **The entry is a bearer one.** Whoever opens it first becomes the confirming person. Show the QR
  only to the person you are asking, and put the context they need to recognise the request into the
  wording.
</Warning>

## 4. Read the outcome

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

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

| `outcome` | Meaning |
| - | - |
| `pending` | No answer yet — ask again in a couple of seconds |
| `confirmed` | The person confirmed |
| `declined` | The person declined |
| `expired` | The time ran out |
| `failed` | The transaction ended without a decision of the person, for example the channel could not continue |

`matched_type` is the name of the comparable type that matched `expected_identities`, or `null`. Only
the client that created the transaction may read it: any other transaction — unknown, removed after
`CompletedRetentionSeconds`, or another client's — answers the same `404 transaction_not_found`.

## 5. Learn who confirmed (optional)

With `AllowConfirmationTokenGrant`, exchange a `confirmed` transaction **once** for the `id_token` of
the confirming person:

```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
```

The `id_token` carries `sub`, and with the `channel` scope `channel_type` and `channel_user_id`. The
rules of the exchange — single redemption, `invalid_grant` for every kind of "no", what the
application learns — are in [Linking from your server](/docs/scenarios/channel-linking#linking-from-your-server-without-a-sign-in).

## Errors of the creation request

Every refusal is Problem Details (`application/problem+json`): the code is in `title`, and `detail`
names no value you sent. Nothing is created on any refusal.

| Status | `title` | What to check |
| - | - | - |
| `401` | — | No token, or an invalid one |
| `403` | — | The token was issued to a user, not to the client |
| `400` | `action_type_missing` | `action_type` is absent or blank |
| `400` | `action_type_unknown` | No declaration of this action type is visible for this client: it is not in the client entry's `MessageTemplates`, or it is only in the host section `Veriqa:MessageTemplates`. The server log names the address it looked at |
| `400` | `ui_config_invalid` | The `ui_config` record does not exist or is not allowed to this client |
| `400` | `locale_invalid` | `locale` is not a well-formed BCP 47 tag |
| `400` | `time_zone_invalid` | `time_zone` is not a zone the server knows |
| `400` | `slot_undeclared` | `slot_values` has a name the contract does not declare |
| `400` | `slot_required_missing` | A required slot has no value |
| `400` | `slot_value_invalid` | A value breaks its slot's type, `MaxLength`, `Values` or bounds |
| `400` | `slot_values_too_large` | `slot_values` exceed 16 KB |
| `400` | `candidate_type_undeclared` / `candidate_value_invalid` | An `expected_identities` type is not declared for this client, or its value has no canonical form |
| `400` | `invalid_idempotency_key` / `idempotency_key_conflict` | The key is longer than 128, or was already used with different parameters |
| `503` | `channel_display_failed` | The transaction exists but no way in could be built right now — for example, no channel is enabled. A repeat with the same `idempotency_key` answers with it again |

## Next steps

<CardGroup cols={2}>
  <Card title="Linking from your server" icon="link" href="/docs/scenarios/channel-linking#linking-from-your-server-without-a-sign-in">
    Learn which messenger account confirmed and store it next to your user.
  </Card>

  <Card title="Veriqa:MessageTemplates" icon="message" href="/docs/reference/configuration#veriqamessagetemplates">
    Ladders, editions, levels and axes of messages.
  </Card>
</CardGroup>


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