# For AI agents Source: https://veriqa.app/docs/ai-agents How a coding agent reads this documentation: the skill file, llms.txt, Markdown versions of every page and the documentation MCP server. This documentation is written for people and is equally available to coding agents — Claude Code, Cursor, Copilot and others. An agent does not have to parse the HTML: every page also exists as Markdown, and the whole set comes in forms meant to be loaded as context. ## What an agent can read | Resource | Address | What it is | | - | - | - | | Skill file | [`/docs/skill.md`](https://veriqa.app/docs/skill.md) | Read this first: what Veriqa is, how to choose between the two mechanisms, recipes for typical tasks, configuration keys, common mistakes and a checklist before finishing | | Page index | [`/docs/llms.txt`](https://veriqa.app/docs/llms.txt) | Every page with a one-line description, in navigation order | | Full text | [`/docs/llms-full.txt`](https://veriqa.app/docs/llms-full.txt) | The whole documentation in one file | | Any page as Markdown | add `.md` to the page address, e.g. [`/docs/quickstart/dotnet.md`](https://veriqa.app/docs/quickstart/dotnet.md) | The page without the site around it | | Documentation search | `https://veriqa.app/docs/mcp` | An MCP server that searches this documentation | The skill file, the index and the full text are in English. Russian pages are not in them, but each one has its Markdown version too, under `/docs/ru/` — e.g. `/docs/ru/quickstart/dotnet.md`. ## Connect the documentation MCP server With the MCP server, the agent searches the documentation itself while it works, instead of relying on what it remembers. ```bash theme={null} claude mcp add --transport http veriqa-docs https://veriqa.app/docs/mcp ``` Add `--scope project` to share the server with your team through `.mcp.json`. Open the menu at the top of any page and choose Cursor or VS Code; the editor installs the server. Use the menu at the top of any page to copy the MCP server address, then paste it into the tool's MCP settings. ## Use one page as context The same menu at the top of every page copies the page as Markdown, opens it as Markdown, or starts a conversation in Claude or ChatGPT with the page attached. It is the quickest way to hand an agent exactly the page the task is about. ## What to tell the agent The agent works better when the task names the mechanism and the deployment mode. A few starting points: * *"Read [https://veriqa.app/docs/skill.md](https://veriqa.app/docs/skill.md), then add sign-in through Veriqa embedded in this ASP.NET Core application. Enable Telegram only."* * *"Read [https://veriqa.app/docs/skill.md](https://veriqa.app/docs/skill.md). This application signs users in through a self-hosted Veriqa — wire it up as a plain OpenID Connect client; do not add any Veriqa package."* * *"Using [https://veriqa.app/docs/reference/confirmation-api.md](https://veriqa.app/docs/reference/confirmation-api.md), ask the account owner to confirm before `DeleteProject` runs, and delete the project only if the owner has confirmed."* An application that only signs users in needs no Veriqa package or SDK — it is an ordinary OpenID Connect relying party. Veriqa packages belong only where the application *is* the issuer or calls the confirmation API from its server. Agents often get this wrong; say it in the task if your application is a client. ## See also * [Samples](/docs/samples) — runnable projects an agent can copy from * [Configuration reference](/docs/reference/configuration) * [Known limitations](/docs/reference/limitations) # Basic scenarios Source: https://veriqa.app/docs/basic-scenarios The two mechanisms every Veriqa scenario is built from — sign-in and registration, and the server-to-server transaction — and how they differ. Every Veriqa scenario is built from one of **two mechanisms**. The user experience is the same in both — a QR code on screen or a button on the phone, then one tap in a messenger — but who starts the transaction and what comes back are different. ## Sign-in and registration The ordinary OpenID Connect flow. Your application sends the user to `/connect/authorize`, the sign-in window shows a QR code (or a channel button on the phone), the user confirms in the messenger, and your application exchanges the code for tokens at `/connect/token`. * **Registration happens in the same step** — there is no form and no password to create. * The result is an `id_token` with `sub`, `auth_time` and the claims you asked for; the session is yours to open. * Your application stays a **plain OIDC client**: no Veriqa package, no SDK, only configuration. Details: [Passwordless sign-in and registration](/docs/scenarios/passwordless-login), [How the auth flow works](/docs/concepts/auth-flow). ## Server-to-server transaction A confirmation your **backend** asks for, with no sign-in and no browser redirect. The backend gets a token with the Client Credentials grant, creates the transaction with `POST /api/transaction/confirmation` for a **declared action type**, shows the user the QR code or link from the response, and reads the outcome with `GET /api/transaction/{id}/result`. * The user sees **wording declared on your host** with typed slot values — "Delete ticket A-900?", "Buy for 4.99 EUR?" — never free text from the caller. * `expected_identities` lets only a specific person confirm — for example the owner of the current session. * No session is created. A confirmed transaction can optionally be exchanged for the `id_token` of the person who confirmed. Details: [Server-to-server confirmation API](/docs/reference/confirmation-api). ## How they differ | | Sign-in and registration | Server-to-server transaction | | - | - | - | | Who starts it | The user's browser or app, by a redirect | Your backend, by an API call | | Protocol | Standard OpenID Connect: `/connect/authorize` → `/connect/token` | Client Credentials + `POST /api/transaction/confirmation` | | What the user confirms | The sign-in to your application | A declared action with its parameters | | Who may confirm | Anyone — this is how a new account appears | Anyone, or only the people named in `expected_identities` | | What you get | `id_token` and tokens; you open the session | The outcome — `confirmed`, `declined`, `expired` or `failed`; optionally the confirming person's `id_token` | | What your application needs | An OIDC client and its configuration | A client entry with `AllowClientCredentials`, a declared action type and a server-side call | **Rule of thumb.** If the result should be "the user is signed in", use sign-in. If the result should be "this action is confirmed" — and your server performs the action — use the server-to-server transaction. ## Scenarios and their mechanism | Scenario | Mechanism | Details | | - | - | - | | Passwordless sign-in and registration | Sign-in | [Passwordless sign-in](/docs/scenarios/passwordless-login) | | Sign-in with channel linking | Sign-in; server-to-server when there is no redirect | [Channel linking](/docs/scenarios/channel-linking) · [Linking from your server](/docs/scenarios/channel-linking#linking-from-your-server-without-a-sign-in) | | Second factor and step-up | Sign-in — a repeat authorize and the `auth_time` check | [Step-up](/docs/scenarios/step-up) | | Confirming a specific action by the signed-in person | Server-to-server with `expected_identities` | [Confirmation API](/docs/reference/confirmation-api) · videos: [desktop](/docs/videos/desktop-step-up), [phone](/docs/videos/mobile-step-up), [TV](/docs/videos/tv-step-up) | | Replacing SMS codes | Sign-in | [Replacing SMS codes](/docs/scenarios/sms-replacement) | | Leads and feedback without CAPTCHA | Sign-in, with no session opened | [Leads without CAPTCHA](/docs/scenarios/lead-capture) · [Confirmed form submission](/docs/integrations/confirmed-form) | | Account recovery | Sign-in through a linked channel | [Account recovery](/docs/scenarios/account-recovery) | | Surfaces with no keyboard | Sign-in where there is a browser; server-to-server where there is none | [Sign-in surfaces](/docs/scenarios/surfaces) | | AI agent approvals | Server-to-server | [Approvals in Claude Code](/docs/research/claude-code-approvals) | Recipes for all sign-in scenarios are collected in [Integration scenarios](/docs/guides/scenarios). # FAQ Source: https://veriqa.app/docs/faq Your own messenger bots, on-premise, the cloud, how it differs from passkeys and what it does about bots — short answers with links to the details. No. The user confirms the sign-in in an app they already have — Telegram, WhatsApp, another messenger or email. There is no separate authenticator to install. There are several channels, plus email — the user picks the one they have. The list is on [Supported channels](/docs/guides/supported-channels). In your own deployment — yes, and that is the normal mode. Channels are configured with your credentials: `Channels:Telegram:BotToken`, `Channels:Max:BotToken` and so on. The bot is yours, the user talks to it under your name, and the tokens live in your secret store. The full list of keys is in the [configuration reference](/docs/reference/configuration). In the cloud the set of enabled channels is decided by the project owner in the Console; ask them whose credentials are in use there. Yes, that is the primary delivery model: Veriqa runs inside your perimeter, next to your auth system. The other option is [Veriqa Cloud](/docs/cloud/overview), for when you would rather not run a server. The same product, run and maintained by someone other than you: you get a project with its own issuer, applications and channel set, managed by the project owner in the Console — with no edits or redeploys of your application. The cloud suits situations where running a server is not worth it: small sites, off-the-shelf CMSes, a quick start. Details and current status are in [Veriqa Cloud](/docs/cloud/overview). Channel messages are not billed per message the way SMS is, so at any noticeable sign-in volume that is a direct saving. Veriqa's own commercial terms are not published in this documentation — ask about them separately. No. Veriqa sits alongside it: your auth system keeps issuing tokens and owning accounts, and Veriqa adds the trusted channel the user confirms in. To an application Veriqa is a standard OpenID Connect provider, so your stack does not matter. No — and this question only concerns .NET. There Veriqa is a connector on top of OpenIddict, which remains the foundation of authentication. On any other stack OpenIddict is not involved at all: the application talks to Veriqa over plain OpenID Connect. Passkeys are phishing-resistant by design — a higher security bar. Veriqa offers the same convenient flow for simpler scenarios, and works where cross-device passkeys break: Bluetooth off, a virtual machine, a remote desktop. In part. Some channels — Telegram, for one — report whether the sender is a bot; Veriqa returns that flag in the claims, and the restriction policy can block such sign-ins. This is not an anti-fraud system, but it is a cheap filter that works **before** an account is created. Yes. It works with WordPress, Drupal, TYPO3, Joomla and Bitrix without changes on the Veriqa side. The caveats: Joomla's free version only signs people in to an **existing** account, Drupal and Joomla require an email address on every sign-in (and Telegram provides none), and the Email channel has been verified with a CMS on Bitrix only. All of it is laid out in [Veriqa in a CMS](/docs/integrations/overview). # Veriqa — an open-source project for passwordless authentication through popular messengers and email: Telegram, WhatsApp, Instagram, Messenger, WeChat, Viber, Email and more Source: https://veriqa.app/docs/index Passwordless authentication through Telegram, WhatsApp, Instagram, Messenger, WeChat, Viber, other channels or Email: a QR code or a single button: your users need no sign-up, no password and no SMS code. Standard OpenID Connect for any stack. ## The idea is simple **For your users** — easy authentication: scanning a QR code or tapping a single button is all it takes to sign in to your app or website. **No** sign-up, **no** extra software, **no** password and **no** SMS code. **For your website or app** — setup in 15 minutes on any stack, and data about the user signing in, straight from the messenger: name, language, photo and, with the user's consent, [phone number](/docs/guides/phone-number). One more benefit: at sign-in your website automatically gets a live channel to the user through their messenger, and you stop paying for SMS. ## What it looks like On a computer, a TV or a kiosk, the user scans the QR code on the sign-in page; on a phone they tap a button. The messenger they already use opens — Telegram, WhatsApp or [another popular one](/docs/guides/supported-channels) — and a single tap there confirms the sign-in. Even a user's first sign-in takes only 4–6 seconds. [See the measurements](/docs/concepts/sign-in-comparison). The same flow can also confirm AI agent actions and sensitive operations: deleting important data, changing payment details, granting access to another person. [Try the live examples](https://veriqa.app/demo?lang=en). [More videos](/docs/videos/tv-sign-in) · [Try the live examples](https://veriqa.app/demo?lang=en) Veriqa is tested with popular CMSs: [WordPress](/docs/integrations/wordpress), [Drupal](/docs/integrations/drupal), [Joomla](/docs/integrations/joomla), [TYPO3](/docs/integrations/typo3) and [Bitrix](/docs/integrations/bitrix). See [Veriqa in a CMS](/docs/integrations/overview) for details. It works with [.NET](/docs/quickstart/dotnet), [Node](/docs/quickstart/node), [Python](/docs/quickstart/python), [Java](/docs/quickstart/java), Go, PHP and any other stack. Veriqa ships as [NuGet packages](/docs/quickstart/dotnet), a [Docker image](/docs/quickstart/self-hosted) or a [self-contained binary](/docs/quickstart/os-service) for Linux and Windows. You can [write a channel in any language](/docs/guides/custom-channel-adapter#an-http-channel-adapter-in-any-language) for your corporate messenger or bot and plug it in without changing the core. The code is open source under MPL-2.0 and free for companies of any size. ## What you get Add a messenger sign-in button to your website, next to the sign-in methods you already have. You get less friction at sign-in, lower costs, stronger security and a live connection with the user. Setting up Veriqa takes 15–30 minutes and costs nothing, and your users get a quick and convenient way to authenticate on your website or in your app. ### For your users No password, no SMS code, no sign-up form. Scanning a QR code or tapping a button, then confirming in the messenger, is all it takes. The messenger is already on their phone. No new app, no authenticator app and no password to forget and recover. On a computer, a TV or a kiosk it is a QR code; on a phone, a button. The confirmation is always the same tap in the messenger. ### For your product Instead of a password, an SMS code and switching between screens — **one familiar action**: opening the camera on the phone. Sign-in or sign-up takes **4–6 seconds**, and that is the whole path: pointing the camera, tapping the link and confirming in the messenger. SMS is billed per message and gets more expensive year over year. Sign-in and confirmation via Veriqa **aren't billed per message**, and Veriqa itself is **open source**. Along with the sign-in, your product gets a **connected messenger channel** — for notifications, confirmations and future service use cases. Nothing for the user to install and **no new client-side code** — Veriqa plugs into your existing app through configuration. “Forgot password” is no longer an ordeal: a **linked trusted channel** becomes a reliable recovery path, without a chain of emails and codes. Veriqa stores the bare minimum: **an encrypted identifier** that each messenger assigns to the user. No names, no phone numbers. The data a sign-in needs appears only during the sign-in and can be kept in memory only. ## Get started Start with the [quickstart](/docs/quickstart/self-hosted): the Veriqa server in a container and sign-in for your app on any stack. ## Use cases Examples of how Veriqa handles everyday tasks for websites and apps. Your website or app shows a QR code, and the user confirms the sign-in in their messenger — **no password needed**. A new user signs up with the same action, **with no form and no CAPTCHA**: the messenger confirmation itself acts as the bot barrier. [Learn more](/docs/scenarios/passwordless-login) · [Video](/docs/videos/desktop-sign-in) Before an AI agent calls a tool or receives access, it asks a human for approval. The request arrives **in the user's own messenger** — a surface the agent does not control — and the answer goes into the audit trail. [Learn more](/docs/research/agent-approvals) · [Video](/docs/videos/claude-code-tool-call) The user signs in through a messenger, and with the same action your product gets a **live channel** to them — Telegram, WhatsApp, another messenger or email, depending on where they confirmed. One action, **two results**: you can use the channel for notifications and service scenarios. [Learn more](/docs/scenarios/channel-linking) Instead of typing a code from an SMS, the user confirms the sign-in with a button in their messenger. You pay **no per-message charge** and get **full authentication** rather than just a delivered code. [Learn more](/docs/scenarios/sms-replacement) · [Video](/docs/videos/mobile-sign-in) Before sending a form, a visitor verifies themselves through a messenger — it takes a few seconds. Your site gets a **verified contact** and a live channel instead of a "dead" address typed into a field. **Tested and working** on WordPress, Bitrix, Drupal, Joomla, TYPO3 and other CMSs. [Learn more](/docs/scenarios/lead-capture) When a user does something **sensitive** — deletes important data, changes payment details, grants someone else access — your product asks them to confirm it. They confirm with **a single tap in their messenger**, with no code to type. [Learn more](/docs/scenarios/step-up) · [Video](/docs/videos/desktop-step-up) There is more: [sign-in on surfaces with no keyboard](/docs/scenarios/surfaces) and [account recovery](/docs/scenarios/account-recovery); the overview is in [Integration scenarios](/docs/guides/scenarios). [Basic scenarios](/docs/basic-scenarios) shows which of the two base mechanisms — sign-in or the server-to-server transaction — each one uses. ## Sign-in methods compared | Sign-in method | Setup: hands-on time | First sign-in | Repeat sign-in | | - | - | - | - | | Password | hours | no data | \~30 s, \~69 s with a second factor | | SMS code | hours of work and days of waiting | no data | \~17 s | | Social sign-in | hours for each provider, plus waiting for app review | 30 s | 3.4 s | | Passkey | minutes, hours with the fallback sign-in | 24 s with the key on the phone | 3–15 s, 18 s with the key on the phone | | **Veriqa** | **15–30 minutes** | **4–6 s** | **4–6 s** | The Veriqa times are for a sign-in with confirmation; with automatic sign-in it takes 2.4–3.5 s. The source of every figure, what the user does step by step and how the setup was assessed are in the [full comparison](/docs/concepts/sign-in-comparison). How Veriqa fundamentally differs from passkeys, messenger sign-in widgets, SMS codes and the device flow is covered [separately](/docs/concepts/comparison). ## Architecture Veriqa is a bridge between your application and the user's trusted messenger. It opens a sign-in or confirmation transaction, binds it to the chosen channel, waits for the user's confirmation and hands the result back — as tokens for the sign-in, or as an outcome to your backend. ```mermaid theme={null} flowchart LR App["Your application
(web / desktop / TV)"] Backend["Your backend"] subgraph Veriqa["Veriqa"] direction TB OI["OIDC endpoints
(OpenIddict)"] TE["Transaction
engine"] CA["Channel
adapters"] end Channel["Trusted app
Telegram / WhatsApp /
other channels / Email"] App <-->|OpenID Connect| OI Backend <-->|Confirmation API| TE OI --- TE TE --- CA CA <--> Channel ``` What Veriqa is made of, block by block, and its main technical characteristics: 1. **Transaction orchestration.** A sign-in is a transaction with an explicit state, a lifetime and a single outcome. It survives a change of device, expires on its own, is idempotent to repeats and lands in the audit trail — instead of a request left hanging somewhere between the browser and the messenger. 2. **Channel adapters.** Telegram, WhatsApp, Email and others are switched on by configuration. A channel of your own needs no change to the core, and it does not have to be .NET: an external service in any language over HTTP, or the bot you already run, as is. Your channels take their place in the chain alongside the built-in ones. 3. **Integration.** Any stack, standard OIDC. On .NET — a connector on top of OpenIddict. Everywhere else — a standard OIDC provider: Node, Java, Python, Go, PHP. In an IAM such as Logto it is added as an ordinary social connector in a couple of minutes. 4. **Settings and multi-project support.** Tokens, channels, the texts and branding of the sign-in page, policies, limits, the audit trail — all of it is configuration, with four levels of ownership, including the core, the tenant and the application, and a defined resolution order. One installation serves many projects and bots, each with its own settings. 5. **Audit trail and observability.** The audit trail follows your rules and can be switched off entirely. Identities are always masked, and tokens are written neither to the log nor to the audit trail — that is how it is built, not a setting you have to remember. Metrics, logs and traces work the same way. 6. **Stack.** .NET 10, shipped as NuGet packages that plug into a running application. Clean architecture; storage in memory, EF Core (PostgreSQL, SQL Server and others) or Redis. The sign-in page is plain JS — no frameworks, no external dependencies. The data does not leave your perimeter. Details are in [Architecture](/docs/concepts/architecture). ## Deployment | Mode | What it is | Start here | | - | - | - | | **Embedded (NuGet)** | Inside your ASP.NET Core host as an OpenIddict connector — no separate service to operate | [Quickstart .NET](/docs/quickstart/dotnet) | | **Standalone (Docker)** | Its own OIDC server in a container; your application, on any stack, doesn't reference any Veriqa package | [Quickstart self-hosted](/docs/quickstart/self-hosted) | | **OS service (archive)** | The same standalone server without a container — a systemd unit or a Windows service; no Docker and no .NET runtime needed | [Install as an OS service](/docs/quickstart/os-service) | Veriqa Cloud is in development; its API contract is described in [Cloud](/docs/cloud/overview). **A note on .NET.** In embedded mode Veriqa **extends** the OpenIddict setup in your ASP.NET Core application rather than replacing it: it runs the sign-in transaction between `/connect/authorize` and `/connect/token` and leaves token issuance to OpenIddict. On any other stack OpenIddict is not involved at all — the application talks to Veriqa over plain OpenID Connect. ## Channels Eight channels are built into the core, including **Telegram, WhatsApp** (direct or through Twilio), **Instagram Direct, Messenger, WeChat, Viber and Email**. Any other channel — a corporate messenger, a bot of your own, a regional platform — connects as an [HTTP channel](/docs/guides/custom-channel-adapter#an-http-channel-adapter-in-any-language) written in any language. Pseudo-channels for sign-in with Google, Apple and Huawei accounts ship as a sample in the repository. Adapters for LINE, Slack, Microsoft Teams, Discord, Zalo, KakaoTalk, Threema and more are already implemented — they live in the `channel-adapters` repository, together with sample hosts (see the [source code](https://veriqa.app/source)). The full list, with the status of each channel, is on the [website's channels page](https://veriqa.app/channels) and in [Supported channels](/docs/guides/supported-channels). # Quickstart .NET Source: https://veriqa.app/docs/quickstart/dotnet Add Veriqa to an ASP.NET Core host as an embedded OpenIddict connector. Veriqa embeds in your ASP.NET Core host and runs as an OpenIddict auth server: it hosts the OIDC endpoints, the sign-in page with QR and channels, the transaction engine and the messenger adapters. No separate process or Docker required. Prefer to keep Veriqa out of your build entirely? It also runs as a **separate server** your app talks to over plain OIDC — in the cloud, in Docker or as an OS service — with no Veriqa package in your dependency tree or SBOM. [Veriqa running elsewhere](#veriqa-running-elsewhere) covers what that changes for a .NET client. Samples use placeholders (`my-app`, bot tokens). Replace them with your own values and never commit real secrets: keep them in user-secrets in development and a secret store in production. A runnable sample lives at `samples/dotnet/inproc/login/` in the repository — the repository address is at [veriqa.app/source](https://veriqa.app/source). Clone it and start from there instead of wiring everything by hand. ## 1. Install the packages ```bash .NET CLI theme={null} dotnet add package Veriqa.Core.AuthServer dotnet add package Veriqa.Core.TransactionEngine # Channels — one metapackage with the base set (Telegram, WhatsApp, Email): dotnet add package Veriqa.Core.BaseChannels # MAX is not part of the metapackage — install it separately when you need it: dotnet add package Veriqa.Core.ChannelAdapter.Max # Only for the EF Core stores on PostgreSQL — the migrations assemblies (step 2): dotnet add package Veriqa.Core.AuthServer.Migrations.PostgreSql dotnet add package Veriqa.Core.TransactionEngine.Migrations.PostgreSql ``` ```xml PackageReference theme={null} ``` **Need a single channel? Install just that one.** Every channel ships on its own and brings only its own SDK: `Veriqa.Core.ChannelAdapter.Telegram`, `.WhatsApp`, `.Max`, `.Email`. A deployment that signs users in through Telegram then never receives an email library. The `Veriqa.Core.ChannelAdapter` package arrives with any of these packages — you do not install it separately. Installing both the metapackage and a separate channel is fine: NuGet resolves it to one copy. ## 2. Register the services `AddVeriqaAuthServer` reads configuration from the host's standard `IConfiguration`. Inside it you choose the OpenIddict store, configure the transaction store and add the channels you need. ```csharp Program.cs theme={null} using Microsoft.EntityFrameworkCore; // UseNpgsql — production lines only (Npgsql arrives with the *.Migrations.PostgreSql packages) using Veriqa.Core.AuthServer.DependencyInjection; using Veriqa.Core.AuthServer.Infrastructure; // UseSecurityHeaders (step 3) using Veriqa.Core.ChannelAdapter.DependencyInjection; // AddTelegram, AddWhatsApp, AddMax, AddEmail using Veriqa.Core.TransactionEngine.DependencyInjection; // UseEfCoreStore — production lines only var builder = WebApplication.CreateBuilder(args); // The production lines below take the connection string from the host's configuration: // var connectionString = builder.Configuration.GetConnectionString("Veriqa"); builder.Services.AddVeriqaAuthServer(builder.Configuration, builder.Environment, authServer => { // OpenIddict store (clients, authorizations, tokens). There is no default — choose it // explicitly. For development — the volatile in-memory one: authServer.UseInMemoryOpenIddictStore(); // For production — a relational provider instead of the line above (provider and connection // string are owned by the host; the migrations ship in the // Veriqa.Core.AuthServer.Migrations.PostgreSql package): // authServer.UseOpenIddictDatabase(ef => ef.UseNpgsql(connectionString, // npgsql => npgsql.MigrationsAssembly("Veriqa.Core.AuthServer.Migrations.PostgreSql"))); // Transaction store authServer.ConfigureTransactionEngine(te => { // For development — InMemory: te.UseInMemoryStore(); // For production — EF Core (the method itself ships in the // Veriqa.Core.TransactionEngine.EntityFrameworkCore package; provider and connection // string are owned by the host; the migrations ship in the // Veriqa.Core.TransactionEngine.Migrations.PostgreSql package): // te.UseEfCoreStore(ef => ef.UseNpgsql(connectionString, // npgsql => npgsql.MigrationsAssembly("Veriqa.Core.TransactionEngine.Migrations.PostgreSql"))); }); // Confirmation channels authServer.AddChannelAdapters(adapters => { adapters.AddTelegram(); adapters.AddWhatsApp(); adapters.AddMax(); adapters.AddEmail(); }); }); ``` **The OpenIddict store has no default.** A host that calls neither `UseInMemoryOpenIddictStore()` nor `UseOpenIddictDatabase(...)` **fails to start** outside the `Development` environment: the startup check stops the application and names the fix. In `Development` the same text is logged as a `Warning` and the host starts. An explicit `InMemory` starts in any environment but warns on every startup that the store is volatile: clients, tokens and authorizations do not survive a restart and are not shared between replicas. **Outside `Development` the token certificates are required too.** These are the OpenIddict keys the server signs and encrypts `id_token` / `access_token` with — not the HTTPS certificate. Without `Veriqa:OpenIddict:Server:SigningCertificatePath` (or `…SigningCertificateBase64`) and the matching `EncryptionCertificate…` key the host fails to start; in `Development` development keys are generated instead. The keys are in the [configuration reference](/docs/reference/configuration#server). Add only the channels you actually use. Each adapter activates only when its configuration section is enabled (`"Enabled": true`) — otherwise only its options and validator are registered. ## 3. Build the middleware pipeline ```csharp Program.cs theme={null} var app = builder.Build(); app.UseSecurityHeaders(); // CSP, X-Frame-Options app.UseStaticFiles(); // SignalR client for live status updates app.UseAuthentication(); app.UseAuthorization(); app.UseRateLimiter(); // Veriqa endpoints: OIDC + channel webhooks + transaction status + SignalR hub app.MapVeriqaAuthServer(); app.Run(); ``` `MapVeriqaAuthServer` maps the **Email channel** endpoints (magic link and the one-tap email) automatically — but only when the channel is enabled in configuration (`Veriqa:Channels:Email:Enabled: true`). No separate call is needed. The `wwwroot/locales` directory is optional: without it channel messages go out in the base language, and the startup warning `Locale files directory not found …` is expected. You do not have to translate anything by hand: ready-made locales (`en`, `ru`, `zh`, `pseudo`) ship in the `Veriqa.Core.ChannelLocales` package — [Localization](/docs/guides/customization#localization) explains how to put them to work. Also expected with the default configuration is `Initiator context geolocation is enabled but the GeoIP provider is unavailable`: sign-in works, the geo fields of the initiator context simply stay empty. In `Development` the startup is also followed by a few dozen diagnostic `info` lines about the resolved configuration — that is normal. ## 4. Configure Channel secrets and OIDC clients are set in configuration. Everything Veriqa owns lives under the root `Veriqa` key: channel sections under `Veriqa:Channels`, auth-server settings alongside them in the same root. ```json appsettings.json theme={null} { "Veriqa": { "Channels": { "Telegram": { "Enabled": true, "InboundVerification": "shared_secret", "BotToken": "YOUR_BOT_TOKEN", "UpdateMode": "Webhook", "WebhookBaseUrl": "https://your-domain.com", "WebhookSecretToken": "YOUR_SECRET" }, "WhatsApp": { "Enabled": false, "InboundVerification": "signature", "Provider": "MetaCloudApi", "BusinessPhoneNumber": "+15551234567", "MetaCloudApi": { "PhoneNumberId": "YOUR_PHONE_NUMBER_ID", "AccessToken": "YOUR_ACCESS_TOKEN", "AppSecret": "YOUR_APP_SECRET", "WebhookVerifyToken": "YOUR_VERIFY_TOKEN" } }, "Max": { "Enabled": false, "InboundVerification": "shared_secret", "BotToken": "YOUR_BOT_TOKEN", "UpdateMode": "Webhook", "WebhookBaseUrl": "https://your-domain.com", "WebhookSecretToken": "YOUR_SECRET" }, "OutcomeNotice": { "DisplayIntent": "ReplacePrompt" } }, "MessageTemplates": { "outcome-receipt-confirmed": { "Contract": { "Slots": [ { "Name": "app", "Type": "string", "Source": "ServerInitiatorContext" } ] }, "ByType": { "login": { "BySurface": { "in-channel-messenger": { "Templates": [ "Signed in to {app} ✅", "Signed in ✅" ] } } } } }, "outcome-receipt-declined": { "Contract": { "Slots": [ { "Name": "app", "Type": "string", "Source": "ServerInitiatorContext" } ] }, "ByType": { "login": { "BySurface": { "in-channel-messenger": { "Templates": [ "Sign-in to {app} declined ❌", "Sign-in declined ❌" ] } } } } } }, "TransactionEngine": { "TransactionTtlSeconds": 300 }, "OpenIddict": { "Clients": [ { "ClientId": "my-app", "DisplayName": "My Application", "AllowedRedirectUris": [ "https://localhost:7020/signin-oidc" ], "AllowedScopes": [ "openid", "profile", "email", "offline_access" ], "AllowRefreshTokens": true } ] } } } ``` **Replace the placeholders before the first run.** `YOUR_BOT_TOKEN` and the other placeholders are not values the host can start with. Telegram and MAX in `UpdateMode: Webhook` — the mode shown above — register their webhook at boot, so a placeholder token keeps the host from starting. WhatsApp is the quieter case: once `"Enabled": true`, its credentials are checked at startup for **presence** only — a missing or empty one does keep the host from starting, a placeholder string passes — so the host comes up clean and the unusable value surfaces only when the first confirmation is sent. Get the real credentials in [Channel setup](/docs/guides/channels) — that is also why exactly one channel is `"Enabled": true` above. Switch the others on when you have their credentials. **The last two sections are what the user reads when the sign-in ends.** `Channels:OutcomeNotice:DisplayIntent` says where the outcome receipt appears: `ReplacePrompt` — the shipped value, stated here so the setting is visible — puts the receipt in place of the message that asked the question, and `NewMessage` leaves the question standing and adds a message of its own. It is an intent rather than a promise: Telegram and MAX honor it, WhatsApp always sends a new message, and Email shows no receipt at all. `MessageTemplates` words those receipts. The product ships its own wordings at `ByType:login:BySurface:…`, and a level exhausts every step of the address before the resolution drops to the level below — so a redeclaration is reached only at the **same** address, which is why `Templates` above sits that deep. `{app}` is a server slot: for a sign-in it is the `DisplayName` of the client. The last step of each ladder uses no slot, because no slot of a receipt is guaranteed. The full rules are in [Configuration reference](/docs/reference/configuration#veriqamessagetemplates). WhatsApp uses the official **Meta Cloud API** (`Provider: MetaCloudApi`); `BusinessPhoneNumber` is in E.164 format for the deep link. Wire only the channels you use — a section without `"Enabled": true` does not activate the channel, which is why the two sections above are shown switched off rather than removed. Full Email setup (SMTP for Pull and inbound for Push) is in [Channel setup](/docs/guides/channels#email). `InboundVerification` is a **required** key of every channel section: it declares how the authenticity of an incoming event of that channel is verified. An enabled channel without it keeps the host from starting. The values above are for the modes shown (`UpdateMode: Webhook`); in `Polling` mode the value is `outbound_fetch`. The table for every channel and mode is in [Channel setup](/docs/guides/channels). **Running on `localhost`? Switch the channel to `Polling`.** The Telegram section above is shown in `Webhook` mode, which needs a public HTTPS address: the host registers the webhook at boot, and Telegram has to be able to reach it. It cannot reach `https://localhost:7300` — the address the [checkpoint](#10-checkpoint) at the end of this page runs on. `Polling` needs no public URL, because Veriqa fetches the updates itself: ```json theme={null} "Telegram": { "Enabled": true, "InboundVerification": "outbound_fetch", "BotToken": "YOUR_BOT_TOKEN", "UpdateMode": "Polling" } ``` The two keys change together: `outbound_fetch` is the value that goes with `Polling`, and `shared_secret` there produces a startup `Warning`. `WebhookBaseUrl` and `WebhookSecretToken` are unused in this mode and can be left out; any other bot channel switches over on the same two keys. Good for reaching a working sign-in before a domain and a proxy exist, not for a deployed instance — and even then, one poller per bot ([running more than one instance](/docs/quickstart/self-hosted#running-more-than-one-instance)). `redirect_uri` is matched against `AllowedRedirectUris` exactly — wildcards are not supported. The startup validator requires absolute URIs; use `https` in production (`http` only for a loopback address such as `localhost` or `127.0.0.1`, in development). ## 5. Wire up the client application Everything above is the issuer side. The client that signs in against it is an **ordinary OIDC relying party**: nothing Veriqa-specific goes into it — this is the same wiring you would write for any OpenID Connect provider. In ASP.NET Core that wiring comes from one package: ```bash theme={null} dotnet add package Microsoft.AspNetCore.Authentication.OpenIdConnect ``` ```csharp Program.cs (the client application) theme={null} using System.Security.Claims; using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication.OpenIdConnect; builder.Services .AddAuthentication(options => { options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme; options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme; }) .AddCookie() .AddOpenIdConnect(options => { // The address of the host running Veriqa. Endpoints and keys are picked up through // discovery ({Authority}/.well-known/openid-configuration) — none are spelled out here. options.Authority = "https://localhost:7300"; options.ClientId = "my-app"; options.ResponseType = "code"; options.UsePkce = true; // Must equal an AllowedRedirectUris entry from step 4 — the comparison is exact. options.CallbackPath = "/signin-oidc"; // A user who presses "Cancel sign-in" on the Veriqa page // comes back with error=access_denied. That is an answer, not a fault: with the path set // the handler redirects to the route mapped below. With no path set the refusal // would go to OnRemoteFailure below, like any other failed return. options.AccessDeniedPath = "/signin-cancelled"; // Every other failed return — Veriqa answering error=server_error, say, because the channel // did not deliver the confirmation — raises OnRemoteFailure; with no handler for it the // return ends in an HTTP 500. The user is taken to a page that offers to start over. The // error text is deliberately not shown to the user: it is written to the log instead. options.Events.OnRemoteFailure = context => { context.HttpContext.RequestServices.GetRequiredService>() .LogWarning(context.Failure, "The sign-in return carried an error."); context.HandleResponse(); context.Response.Redirect("/signin-failed"); return Task.CompletedTask; }; // The handler requests openid and profile itself — only extra scopes go here. // Each one must be listed in the client's AllowedScopes on the issuer. options.Scope.Add("email"); options.GetClaimsFromUserInfoEndpoint = true; options.SaveTokens = true; // Veriqa issues the user's name as the short OIDC `name` claim, while User.Identity.Name // looks up ClaimTypes.Name (the long WS-Federation URI) by default — without this line the // page below greets an empty name after a successful sign-in. options.TokenValidationParameters.NameClaimType = "name"; }); builder.Services.AddAuthorization(); ``` An ordinary `Challenge` sends an unauthenticated visitor to sign-in — onto the Veriqa page with the QR code and channel buttons: ```csharp Program.cs (the client application) theme={null} app.UseAuthentication(); app.UseAuthorization(); app.MapGet("/", (ClaimsPrincipal user) => user.Identity?.IsAuthenticated is true ? Results.Text($"Hello, {user.Identity.Name}!") : Results.Challenge()); // Where the handler sends a user who did not go through with the sign-in (AccessDeniedPath above). // It is a route of its own because "/" challenges an anonymous visitor: sent there, the user would // be put straight back on the sign-in page they have just left. app.MapGet("/signin-cancelled", () => Results.Text("Sign-in was canceled. Open / to sign in again.")); // Where OnRemoteFailure above sends a user whose sign-in ended in an error. Like the route before // it, it is anonymous and starts nothing by itself: redirected to "/", a failure that repeats // would loop. app.MapGet("/signin-failed", () => Results.Text("Sign-in could not be completed. Open / to sign in again.")); ``` The second route is where a sign-in that did not happen ends. The sign-in page has a [**Cancel sign-in** button](/docs/guides/customization#cancel-button), on by default; a user who presses it comes back to `/signin-oidc` with `error=access_denied`. `AccessDeniedPath` is what turns that answer into a redirect to this route — the handler has no default for it, and without the line the refusal goes to `OnRemoteFailure`, that is, to the third route. The third route takes every other failed return. A sign-in that breaks on the Veriqa side comes back with `error=server_error` — see [Troubleshooting](/docs/guides/troubleshooting). `AccessDeniedPath` covers `access_denied` only: for the rest the handler raises `OnRemoteFailure`, and with no handler for that event the return ends in an HTTP 500. The page offers to start again and does not show the error text; the handler writes it to the application log, which names the error code and explains failures on the client side, while the reason for a `server_error` is in the Veriqa log and in the audit trail. Three values have to agree with the configuration from step 4. Each mismatch has its own symptom, which is how you tell them apart: | Client | Issuer (`Veriqa:OpenIddict:Clients[]`) | If it does not match | | - | - | - | | `Authority` | the address of the host running `MapVeriqaAuthServer` | the client fails at discovery on `{Authority}/.well-known/openid-configuration` — there is no OIDC error response at all | | `ClientId` | `ClientId` | `invalid_client` from OpenIddict | | `CallbackPath` (on the client's address) | an `AllowedRedirectUris` entry | `invalid_request`: the comparison is literal, wildcards are not supported | `dotnet new web` gives every project random ports, so out of the box neither `Authority` nor the `redirect_uri` points at a running process — [step 10](#10-checkpoint) starts both on the addresses used here. The `my-app` client from step 4 is declared **without** a `ClientSecret`, which makes it a public client — and Veriqa requires PKCE (S256) from it. Hence `UsePkce = true` and no `ClientSecret`. For a confidential client the secret is set on **both** sides. A runnable "issuer + client" pair lives in the repository ([veriqa.app/source](https://veriqa.app/source)): `samples/dotnet/inproc/login` (the host with Veriqa, port 7300) and `samples/dotnet/inproc/login-client` (this application, port 7020\). Their configuration matches out of the box — start both and walk through the sign-in. For it to **reach the end**, the issuer needs one enabled channel with credentials: the sample ships with every channel `"Enabled": false`, and with none enabled the sign-in page answers `no_channels_available`. Veriqa exposes no end-session endpoint, so federated sign-out is not supported: signing out on the client clears its own cookie session but not the issuer's. ## 6. How the sign-in flow works The user starts the transaction on the desktop and confirms it in a trusted channel on their phone. Veriqa tracks the transaction and only then lets OpenIddict issue the result. ```mermaid theme={null} sequenceDiagram autonumber participant App as Client app (desktop) participant V as Veriqa (in your host) participant OI as OpenIddict participant Ch as Trusted channel (phone) App->>V: Start auth transaction (/connect/authorize) V-->>App: Sign-in page + QR / channel buttons Ch->>V: User opens channel, confirms sign-in V->>V: Update transaction state App->>V: Live status (SignalR) or polling opt Confirmation on the web page (by default for Email) V-->>App: Confirmation page (/connect/authorize/callback) App->>V: User confirms (/connect/authorize/callback/confirm) end V->>OI: Complete authentication OI-->>App: code → /connect/token → tokens ``` ## 7. Endpoints `MapVeriqaAuthServer` maps the standard OIDC contract and the service endpoints: | Endpoint | Method | Purpose | | - | - | - | | `/connect/authorize` | GET | Enter the Authorization Code Flow | | `/connect/authorize/callback` | GET | Callback after channel confirmation | | `/connect/authorize/callback/confirm` | POST | On-page sign-in confirmation button. `Veriqa:LoginConfirmation:Mode` defaults to `ChannelDefault`: a channel that can confirm inside itself (Telegram, WhatsApp, MAX) asks there, Email asks on this page; `OnWebPage` asks on this page for every channel | | `/connect/authorize/callback/claims` (+ `/skip`, `/cancel`, `/email`) | POST | Actions of the page that collects profile fields the sign-in is still missing: submit the fields, skip the optional ones, cancel the sign-in; `/email` — the actions of its email block (send a code or a link, check the code, save the address, switch the way, skip the email) | | `/auth/claims?session_id=…&form_token=…` | GET | The same page on the device where the sign-in was confirmed, opened by a link that carries a form token valid for one sign-in; its form posts to the actions above with that token instead of the browser cookie | | `/connect/token` | POST | Exchange `code` for tokens | | `/connect/userinfo` | GET | User claims | | `/connect/revoke` | POST | Token revocation (RFC 7009) — handled natively by OpenIddict | | `/api/transaction/{id}/status` | GET | Transaction status polling (non-WebSocket fallback) | | `/api/transaction/confirmation` | POST | Server-to-server creation of a confirmation transaction: the application authenticates with client credentials and receives a `transaction_id` and a way in for the user | | `/api/transaction/{id}/result` | GET | Result of a confirmation transaction (application token required) | | `/api/link-handle/redeem` | POST | Server-to-server exchange of a [link handle](/docs/scenarios/channel-linking#two-bots-handing-the-user-over-to-your-bot) from a post-completion button for the identity of the signed-in user (application token required) | | `/api/collected-claims?sub=…` | DELETE | Deletion of the collected claims of a subject (client credentials of a client with `AllowCollectedClaimsDeletion`); mapped only when a [collected claims store](/docs/guides/storage#collected-claims-store) is registered | | `/auth/transaction?session_id=…` | GET | Entry page of an already created confirmation transaction | | `/auth/transaction/confirm` (+ `/accept`, `/decline`) | GET / POST | Confirmation question page and the user's answer | | `/hubs/auth` | WebSocket (SignalR) | Live transaction status for the sign-in page | | `/api/channels/telegram/webhook` | POST | Telegram adapter webhook | | `/api/channels/whatsapp/webhook` | GET/POST | WhatsApp adapter verification and webhook | | `/api/channels/max/webhook` | POST | MAX adapter webhook | The Email channel endpoints are mapped by the same `MapVeriqaAuthServer` call automatically — only when the channel is enabled (`Veriqa:Channels:Email:Enabled: true`): | Endpoint | Method | Purpose | | - | - | - | | `/auth/email/start` | GET/POST | Email input page and magic link delivery (Pull) | | `/auth/email/confirm` | GET/POST | Magic link confirmation (Pull) | | `/auth/email/claim` | GET/POST | Email address confirmation by the link of a claims-completion email | | `/api/channels/email/inbound` | POST | Inbound email webhook from the inbound provider (Push) | ## 8. User claims The default mapper builds the final claim set from the completed transaction: | Claim | Meaning | | - | - | | `sub` | Stable subject identifier (required) | | `amr` | Authentication method — the confirmation channel type (`telegram`, `whatsapp`, `max`, `email`). In the `id_token` it is an **array of strings** even for a single method: `"amr": ["telegram"]` (OIDC Core 1.0 §2). The same method is also visible in the `access_token` and the `/connect/userinfo` response | | `auth_time` | When authentication completed (Unix) | | `name`, `email`, … | Extra claims from the resolved identity — depend on the channel and requested scopes | | `channel_type`, `channel_user_id` | The confirmation channel and the user's id inside it — **only with the Veriqa `channel` scope** | | `picture` | The user's avatar — **only with the Veriqa `avatar` scope** | `picture` arrives only when all three hold: the client requests the `avatar` scope (`options.Scope.Add("avatar")`), the scope is listed in the client's `AllowedScopes`, and `GetClaimsFromUserInfoEndpoint = true` — the avatar is never put into the `id_token`, only userinfo returns it. To read it without inflating the cookie, see [OIDC + Veriqa explainer](/docs/concepts/oidc-explainer#resolved-identity-and-claims). Override the mapping with your own `IClaimsMapper` — see [Configuration & customization](/docs/guides/customization#claims-mapper). ## 9. Channel selection via acr\_values A client can restrict sign-in to a specific channel with the standard OIDC `acr_values` parameter in `channel:{type}` form: ``` GET /connect/authorize ?client_id=my-app &response_type=code &scope=openid profile &acr_values=channel:telegram ``` Multiple values (`channel:telegram channel:whatsapp`) let the user pick from the listed ones. Without the parameter, all enabled channels are available. ## 10. Checkpoint Start both processes on the addresses the configuration expects — the issuer on the `Authority` port, the client on the port of its `redirect_uri`: ```bash theme={null} # In the issuer project (the host with Veriqa): dotnet run --urls "https://localhost:7300" # In the client project, in a second terminal: dotnet run --urls "https://localhost:7020" ``` Putting the same addresses into `applicationUrl` of each project's `Properties/launchSettings.json` works too. The client calls the issuer over `https` itself, so the ASP.NET Core development certificate has to exist and be trusted: create it with `dotnet dev-certs https`, then trust it with `dotnet dev-certs https --trust`. On Linux the trust command works as well and prints the `SSL_CERT_DIR` value to set; the `There was an error trusting the HTTPS developer certificate.` line it ends with is **expected** there and does not mean the step failed — export the value it printed, keeping your distribution's own certificate directory in the list so that other chains (a `restore` from nuget.org, any outbound HTTPS call) still verify: ```bash theme={null} export SSL_CERT_DIR="$HOME/.aspnet/dev-certs/trust:$(openssl version -d | cut -d'"' -f2)/certs" ``` The second entry is the certificate directory of your distribution, and the command works out where it is instead of hard-coding it: `openssl version -d` prints `OPENSSLDIR` — the OpenSSL configuration directory, not the certificates themselves — and the CA files with the hash links OpenSSL looks up sit in its `certs` subdirectory, hence the `/certs` on the end. On Debian and Ubuntu that resolves to `/usr/lib/ssl/certs`; on other distributions the prefix differs, which is why it is read rather than written out. After that the certificate is trusted. Background is in the "Trust HTTPS certificate on Linux" section of [Enforce HTTPS in ASP.NET Core](https://learn.microsoft.com/aspnet/core/security/enforcing-ssl). Start both processes as shown above and open the client at `https://localhost:7020`. Choose "Sign in with Veriqa" and scan the QR code with a trusted channel on your phone. Email shows a QR code in Push mode; in Pull mode it shows an address field and a "Get a link" button instead. Confirm the request in Telegram / WhatsApp / other channels / Email. The desktop updates via SignalR or polling. With Email — and with any channel when `Veriqa:LoginConfirmation:Mode` is `OnWebPage` — the sign-in page then asks once more: confirm the sign-in there. Check that you land authenticated and that the expected claims are present. ## Veriqa running elsewhere Everything above is the **embedded** mode: Veriqa is a set of packages inside your own host. The same server can instead run **separately**, and your application signs in against it over plain OIDC — Veriqa Cloud, a Docker container of your own, or a systemd unit / Windows service on a machine of your own. For a .NET application that changes the dependency tree, not the code: you keep the client half of [step 5](#5-wire-up-the-client-application) and drop every Veriqa package, so no Veriqa component enters your build or your SBOM. **Veriqa Cloud is currently unavailable** — the cloud row below describes the contract of its API. Use self-hosted; details are in [Veriqa Cloud](/docs/cloud/overview). | Mode | The `Authority` the client points at | Where the client is registered | | - | - | - | | Cloud | The project address, `https:///p/{project}` — read-only on the Project settings screen | The **Applications / OIDC clients** section of the console — see [Connecting to the cloud](/docs/cloud/connect) | | Self-hosted | The address of your own server, e.g. `https://auth.your-domain.com` | The `Veriqa:OpenIddict:Clients` section of the server configuration — see [step 4 of the self-hosted quickstart](/docs/quickstart/self-hosted#4-declare-your-client) | | Embedded (everything above) | The address of the host Veriqa sits in, e.g. `https://localhost:7300` | The same section of that host's own configuration — [step 4](#4-configure) | The wiring is the one from [step 5](#5-wire-up-the-client-application), with a single difference: the issuer address comes from configuration instead of code, because it is the one value that changes between the modes and between environments. ```csharp Program.cs (the client application) theme={null} builder.Services .AddAuthentication(/* the two schemes of step 5 */) .AddCookie() .AddOpenIdConnect(options => { // The only value that differs between cloud, self-hosted and embedded. Everything else — // endpoints, keys, supported scopes — the client reads from discovery at startup. options.Authority = builder.Configuration["Veriqa:Authority"]; options.ClientId = builder.Configuration["Veriqa:ClientId"]; options.ResponseType = "code"; options.UsePkce = true; options.CallbackPath = "/signin-oidc"; options.AccessDeniedPath = "/signin-cancelled"; options.Events.OnRemoteFailure = context => { context.HttpContext.RequestServices.GetRequiredService>() .LogWarning(context.Failure, "The sign-in return carried an error."); context.HandleResponse(); context.Response.Redirect("/signin-failed"); return Task.CompletedTask; }; options.TokenValidationParameters.NameClaimType = "name"; }); ``` ```json appsettings.json (the client application) theme={null} { "Veriqa": { "Authority": "https://auth.your-domain.com", "ClientId": "my-app" } } ``` A confidential client adds `options.ClientSecret` — from user-secrets in development and a secret store in production, never from `appsettings.json`. In the cloud the secret is issued by a separate explicit action on the Applications screen and shown **once**. Nothing else on this page becomes mode-specific: the endpoints of [step 7](#7-endpoints), the claims of [step 8](#8-user-claims) and channel restriction through `acr_values` ([step 9](#9-channel-selection-via-acr-values)) are issuer behavior, and the client sees them through discovery whichever mode it talks to. **How the separate server is delivered** is a further choice, and it changes nothing on the client side: a Docker container — [self-hosted quickstart](/docs/quickstart/self-hosted) — or an archive installed as a systemd unit or a Windows service, with no Docker and no .NET runtime on the machine — [Install as an OS service](/docs/quickstart/os-service). ## Next steps Telegram, WhatsApp, other channels and Email — end to end. Every section, key and default in one place. Sign-in page design, localization, extension points. What to check before shipping to production. Where Veriqa sits in a standard OIDC flow. The building blocks behind the connector. The same issuer outside your build — in Docker or as an OS service. # Java quickstart Source: https://veriqa.app/docs/quickstart/java Connecting a Java application to Veriqa over standard OpenID Connect — cloud, Docker or an OS service. For non-.NET stacks Veriqa is an ordinary OpenID Connect provider. There is no Veriqa package for Java, and none is needed: you plug in a standard OIDC client, and Veriqa handles all the specifics (sign-in page, QR code, confirmation in the messenger) on its own side. **One codebase, any deployment.** The application never depends on **where** Veriqa runs — Veriqa Cloud, a Docker container of your own, or a systemd unit / Windows service on a machine of your own. The deployments differ **only in the issuer address** and in where the client is registered; the code below is identical for all of them, and switching comes down to a single `issuer-uri` value. ## 1. Add the starters The mainstream path for Java is **Spring Security OAuth2 Client** through the Spring Boot starter. No version is needed: the Spring Boot BOM manages it. A second starter goes with it — the OAuth2 one declares `spring-boot-starter`, Spring Security and the OAuth2/OIDC client, and **no web server**, so on its own the application starts and exits without ever serving the callback: ```xml pom.xml theme={null} org.springframework.boot spring-boot-starter-oauth2-client org.springframework.boot spring-boot-starter-web ``` ```groovy build.gradle theme={null} implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' implementation 'org.springframework.boot:spring-boot-starter-web' ``` Together they bring everything required: the embedded Tomcat and Spring MVC the controller of [step 5](#5-read-the-claims) needs, plus the OAuth2/OIDC client, callback handling and the Spring Security integration. ## 2. Register a client on the Veriqa side **Veriqa Cloud is currently unavailable** — the cloud rows describe its API contract. Use self-hosted; details are in [Veriqa Cloud](/docs/cloud/overview). | Mode | Where the client is registered | What you get | | - | - | - | | Cloud | The **Applications / OIDC clients** section of the console. A default application already exists alongside the project — a **public client with PKCE, no secret and no `redirect_uri`**, so adding your `redirect_uri` comes first. The `issuer` address is on the Project settings screen, read-only | `issuer`, `client_id`, `redirect_uri` | | Self-hosted | The `Veriqa:OpenIddict:Clients` section of the server configuration — see [step 4 of the self-hosted quickstart](/docs/quickstart/self-hosted#4-declare-your-client). The same section whichever way the server is delivered | The same, with values you choose | **Self-hosted comes in two deliveries, and this page is the same for both.** The server runs in a Docker container — [self-hosted quickstart](/docs/quickstart/self-hosted) — or is installed from an archive as a systemd unit or a Windows service, with no Docker and no .NET runtime on the machine — [Install as an OS service](/docs/quickstart/os-service). The choice decides how the server is delivered, not the client registration, the issuer address or any of the code below. **A secret is not mandatory.** The minimal working set is `issuer`, `client_id`, `redirect_uri`: a public client with PKCE, and that is the cloud default. A confidential client is only needed when you want to authenticate a server-side application with a secret; in the cloud the secret is issued by a separate explicit action on the Applications screen and is shown **once**. **The `redirect_uri` is matched exactly** — wildcards are not supported. Spring Security uses the `{baseUrl}/login/oauth2/code/{registrationId}` template by default, so with `registrationId` = `veriqa` and an application at `https://app.example.com` you must register exactly `https://app.example.com/login/oauth2/code/veriqa` in Veriqa. This is the most common reason a first sign-in is rejected. For the application started as in [step 7](#7-checkpoint) that URL is `http://127.0.0.1:8080/login/oauth2/code/veriqa` — register exactly that URL, and open the application at the same host (`127.0.0.1`, not `localhost`): the callback URL is built from the host of the incoming request, and the comparison on the Veriqa side is literal. ## 3. Describe the client registration `issuer-uri` turns on discovery: Spring reads `/.well-known/openid-configuration` itself and finds the remaining endpoints. ```yaml application.yml — public client (PKCE) theme={null} spring: security: oauth2: client: registration: veriqa: client-id: my-app client-authentication-method: none authorization-grant-type: authorization_code scope: openid, profile, channel provider: veriqa: issuer-uri: https://auth.your-domain.com ``` ```yaml application.yml — confidential client theme={null} spring: security: oauth2: client: registration: veriqa: client-id: my-app client-secret: ${VERIQA_CLIENT_SECRET} authorization-grant-type: authorization_code scope: openid, profile, channel provider: veriqa: issuer-uri: https://auth.your-domain.com ``` **PKCE needs no separate switch.** Spring Security applies it automatically when `client-authentication-method` is `none`; no extra configuration is necessary for a public client. The secret is then not set at all — by leaving the key out, not by an empty string. `issuer-uri` is the address [step 2](#2-register-a-client-on-the-veriqa-side) gave you — replace `https://auth.your-domain.com` with it, and `client-id` with the client you registered there. Spring validates the discovery document against this value at startup, so a stale placeholder fails the start rather than the first sign-in. Keep secrets in the environment (`${VERIQA_CLIENT_SECRET}`), not in a committed `application.yml`. `${…}` is resolved by Spring from the environment of the process, so the confidential variant needs the variable exported in the shell the application is started from: ```bash theme={null} export VERIQA_CLIENT_SECRET="…" # confidential client only ``` The public variant reads no secret at all: with `client-authentication-method: none` the key is absent from `application.yml`, not empty. If the variable is unset for the confidential one, the start fails on the unresolved placeholder. Switching between deployments — cloud, Docker, OS service — comes down to changing `issuer-uri` alone. ## 4. Turn on sign-in A minimal configuration is enough: `oauth2Login()` drives the whole flow — the redirect to the Veriqa sign-in page, the callback handling and session creation. ```java SecurityConfig.java theme={null} @Configuration @EnableWebSecurity public class SecurityConfig { @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests(authorize -> authorize .anyRequest().authenticated() ) .oauth2Login(Customizer.withDefaults()); return http.build(); } } ``` From here Veriqa takes over: the user sees the sign-in page with a QR code and the channel list, confirms the sign-in in a messenger on their phone, and the browser returns to your `redirect_uri`. A user who clicks [**Cancel sign-in**](/docs/guides/customization#cancel-button) on that page comes back with `error=access_denied`. `oauth2Login()` needs no code for it: a failed sign-in is redirected to `/login?error`, the login page Spring Security generates. ## 5. Read the claims The authenticated user is available as an `OidcUser`: ```java ProfileController.java theme={null} @RestController public class ProfileController { @GetMapping("/") public String home(@AuthenticationPrincipal OidcUser user) { String subject = user.getSubject(); Instant confirmedAt = user.getAuthenticatedAt(); // the auth_time claim String channelType = user.getClaimAsString("channel_type"); return "Hello, " + user.getFullName() + " (" + subject + ", " + channelType + ")"; } } ``` This is the route the checkpoint of [step 7](#7-checkpoint) opens: it is behind `anyRequest().authenticated()`, so the first visit starts the sign-in, and the answer after it shows what the sign-in produced. Veriqa adds the base set of claims to the tokens itself: | Claim | Meaning | Where | | - | - | - | | `sub` | Stable subject identifier | both tokens | | `auth_time` | When the confirmation completed — `getAuthenticatedAt()` | `id_token` only | | `amr` | The channel type that confirmed the sign-in (`telegram`, `whatsapp`, `max`, `email`) | both tokens; an array of strings in the `id_token` | | `name`, `email`, `phone_number`, … | From the resolved identity — depending on the requested scopes | both tokens | | `picture` | The user's avatar — only with the `avatar` scope, also listed in `AllowedScopes` ([details](/docs/concepts/oidc-explainer#resolved-identity-and-claims)) | `access_token` only — read it from userinfo | **Channel claims are issued only with the `channel` scope:** | Claim | Meaning | | - | - | | `channel_type` | The channel type the user is linked through | | `channel_user_id` | The user identifier inside that channel | Without the requested `channel` scope these two claims end up in **no** token — and therefore do not appear in the userinfo response either, which mirrors the access token. An application uses exactly these claims to tell linked channels apart, so the scope is mandatory for account linking. `amr` arrives in the `id_token` as an **array of strings** — that is how OIDC Core 1.0 §2 defines it, and it stays an array even with a single authentication method. Read it from the ID token and take the first element: ```java theme={null} List amr = user.getIdToken().getClaimAsStringList("amr"); String channel = (amr == null || amr.isEmpty()) ? null : amr.get(0); ``` The same method is in the access token as well, so it also shows up in the `/connect/userinfo` response — which Spring calls when `profile` is requested (as in the configuration above) and merges with the `id_token` claims. In userinfo the value is a plain string rather than an array, so `user.getClaimAsString("amr")` on the merged set depends on which claim won the merge; reading through `getIdToken()` has no such fork. For **linking a channel to an account** that is still not enough: `amr` names the channel type but not the user identifier inside it. The identifier comes from `channel_user_id` under the `channel` scope. ## 6. Restrict sign-in to a specific channel The standard OIDC `acr_values` parameter in the `channel:{type}` form narrows the choice on the sign-in page. In Spring Security it is added through an authorization request customizer: ```java SecurityConfig.java theme={null} // The registration repository is provided by the starter — injecting it into the same class is enough @Autowired private ClientRegistrationRepository clientRegistrationRepository; private OAuth2AuthorizationRequestResolver authorizationRequestResolver( ClientRegistrationRepository clientRegistrationRepository) { DefaultOAuth2AuthorizationRequestResolver resolver = new DefaultOAuth2AuthorizationRequestResolver( clientRegistrationRepository, "/oauth2/authorization"); resolver.setAuthorizationRequestCustomizer(customizer -> customizer .additionalParameters(params -> params.put("acr_values", "channel:telegram"))); return resolver; } ``` The resolver is then wired into the chain: ```java SecurityConfig.java theme={null} .oauth2Login(oauth2 -> oauth2 .authorizationEndpoint(authorization -> authorization .authorizationRequestResolver( authorizationRequestResolver(this.clientRegistrationRepository)) ) ); ``` Several space-separated values (`channel:telegram channel:whatsapp`) let the user pick from the listed ones. Without the parameter every channel enabled on the server is available. ## 7. Checkpoint Start the application from the project directory, in the shell where [step 3](#3-describe-the-client-registration) exported the secret (the confidential variant only): ```bash Maven theme={null} mvn spring-boot:run ``` ```bash Gradle theme={null} ./gradlew bootRun ``` Spring Boot serves it on `http://127.0.0.1:8080` — the address the `redirect_uri` registered in [step 2](#2-register-a-client-on-the-veriqa-side) is built from. `curl https://auth.your-domain.com/.well-known/openid-configuration` — your `issuer-uri` in place of the host — returns metadata with `https` URLs. Open `http://127.0.0.1:8080/` — it is protected by `anyRequest().authenticated()`, so Spring sends the browser to the Veriqa page with the QR code and the channel list. Confirm the request in a trusted channel; the browser returns to `http://127.0.0.1:8080/login/oauth2/code/veriqa` and Spring redirects it back to `/`. `/` answers with the greeting of [step 5](#5-read-the-claims): `OidcUser` carries `sub`, and with the `channel` scope requested `channel_type` is available on it. ## Next steps Channel linking, re-confirmation and action approval — at the HTTP level. Deploy your own issuer in Docker. The same issuer without Docker — systemd or a Windows service, from an archive. Where Veriqa fits into the standard flow. What to check before shipping. # Node.js quickstart Source: https://veriqa.app/docs/quickstart/node Connecting a Node.js application to Veriqa over standard OpenID Connect — cloud, Docker or an OS service. For non-.NET stacks Veriqa is an ordinary OpenID Connect provider. There is no Veriqa package for Node.js, and none is needed: you write a standard OIDC client, and Veriqa handles all the specifics (sign-in page, QR code, confirmation in the messenger) on its own side. **One codebase, any deployment.** The application never depends on **where** Veriqa runs — Veriqa Cloud, a Docker container of your own, or a systemd unit / Windows service on a machine of your own. The deployments differ **only in the issuer address** and in where the client is registered; the code below is identical for all of them, and switching comes down to a single environment variable. ## 1. Install the packages The recommended OIDC library is [`openid-client`](https://github.com/panva/openid-client) (OpenID Foundation certified, current line **6.x**). It brings neither a web framework nor a session store, and the code below needs both: ```bash theme={null} npm install openid-client express express-session ``` `express` is the framework the routes are written for; `express-session` is what gives them `req.session` — where the PKCE `code_verifier` and the `state` are kept while the user is away. Without either package, the first run fails on the import line with `ERR_MODULE_NOT_FOUND`, before the application reaches Veriqa at all. **Node.js 20 or newer** is required — that is the minimum declared by the library. The 6.x line is a complete API rewrite: examples written for 5.x (`Issuer.discover`, `client.callback`) do **not** apply to it. **The example is ESM.** `config.js` awaits discovery at the top level of the module, which CommonJS cannot do. Add `"type": "module"` to your `package.json` — without it `node server.js` fails with `Cannot use import statement outside a module`. ## 2. Register a client on the Veriqa side **Veriqa Cloud is currently unavailable** — the cloud rows describe its API contract. Use self-hosted; details are in [Veriqa Cloud](/docs/cloud/overview). | Mode | Where the client is registered | What you get | | - | - | - | | Cloud | The **Applications / OIDC clients** section of the console. A default application already exists alongside the project — a **public client with PKCE, no secret and no `redirect_uri`**, so adding your `redirect_uri` comes first. The `issuer` address is on the Project settings screen, read-only | `issuer`, `client_id`, `redirect_uri` | | Self-hosted | The `Veriqa:OpenIddict:Clients` section of the server configuration — see [step 4 of the self-hosted quickstart](/docs/quickstart/self-hosted#4-declare-your-client). The same section whichever way the server is delivered | The same, with values you choose | **Self-hosted comes in two deliveries, and this page is the same for both.** The server runs in a Docker container — [self-hosted quickstart](/docs/quickstart/self-hosted) — or is installed from an archive as a systemd unit or a Windows service, with no Docker and no .NET runtime on the machine — [Install as an OS service](/docs/quickstart/os-service). The choice decides how the server is delivered, not the client registration, the issuer address or any of the code below. Whichever you pick, the client must declare its allowed redirect URIs (matched **exactly**, wildcards are not supported) and allowed scopes — including `channel` if the application needs the channel claims (step 7). The `redirect_uri` is compared literally, so register precisely the URL your callback route serves. For the server of [step 6](#6-wire-it-into-a-server), started as in [step 9](#9-checkpoint), that URL is `http://127.0.0.1:3000/callback` — register exactly that URL, and open the application at the same host (`127.0.0.1`, not `localhost`). **A secret is not mandatory.** The minimal working set is `issuer`, `client_id`, `redirect_uri`: a public client with PKCE, and that is the cloud default. A confidential client is only needed when the application is server-side and you want to authenticate it with a secret; in the cloud the secret is issued by a separate explicit action on the Applications screen and is shown **once** — store it right away. Keep the values in the environment, not in code. Set them in the shell you will start the application from — the first two are what step 2 gave you, the last two are yours: ```bash theme={null} export VERIQA_ISSUER="https://auth.your-domain.com" # the issuer address export VERIQA_CLIENT_ID="my-app" # the client you registered export VERIQA_CLIENT_SECRET="…" # confidential client only export APP_REDIRECT_URI="http://127.0.0.1:3000/callback" export SESSION_SECRET="$(openssl rand -hex 32)" ``` `SESSION_SECRET` is not issued by Veriqa — it is a random string of your own, the key `express-session` signs the session cookie with. Keep it out of version control and use a separate value in every environment. The public variant needs no `VERIQA_CLIENT_SECRET`: a public client has no secret at all. If a variable your variant reads is missing, the run fails while `config.js` is still being imported — `new URL(undefined)` throws `ERR_INVALID_URL` — before any route is served. A `.env` file works just as well if you prefer one: Node.js 20.6 and newer reads it with `node --env-file=.env server.js`. Switching between deployments — cloud, Docker, OS service — comes down to changing `VERIQA_ISSUER` alone. In the cloud the `issuer` is derived from the project slug. Changing the slug changes the issuer and **breaks existing integrations** — it is a separate owner action in the Dangerous zone, not a routine settings edit. ## 3. Run discovery The client is built once at process start: the library reads `/.well-known/openid-configuration` itself and remembers the endpoints. ```js config.js — public client (PKCE) theme={null} import * as client from 'openid-client' const issuer = new URL(process.env.VERIQA_ISSUER) // The fourth argument is the client authentication method. // A public client has none: only client_id goes to the token endpoint. export const config = await client.discovery( issuer, process.env.VERIQA_CLIENT_ID, undefined, client.None(), ) ``` ```js config.js — confidential client theme={null} import * as client from 'openid-client' const issuer = new URL(process.env.VERIQA_ISSUER) export const config = await client.discovery( issuer, process.env.VERIQA_CLIENT_ID, process.env.VERIQA_CLIENT_SECRET, ) ``` The first argument is a `URL` object, not a string. If discovery returns `http` endpoint addresses while you access it over `https`, the client is not at fault: the self-hosted server sits behind a proxy with no trusted headers — see [step 6 of the self-hosted quickstart](/docs/quickstart/self-hosted#6-put-it-behind-a-reverse-proxy). ## 4. Start the sign-in PKCE is mandatory and `state` protects against CSRF. Both values must be **stored in the session** until the user comes back: they are required in the callback. ```js login.js theme={null} import * as client from 'openid-client' import { config } from './config.js' export async function startLogin(req, res) { const code_verifier = client.randomPKCECodeVerifier() const code_challenge = await client.calculatePKCECodeChallenge(code_verifier) const state = client.randomState() // Store both values in the user session — the callback requires them req.session.code_verifier = code_verifier req.session.state = state const parameters = { redirect_uri: process.env.APP_REDIRECT_URI, scope: 'openid profile channel', code_challenge, code_challenge_method: 'S256', state, } res.redirect(client.buildAuthorizationUrl(config, parameters).href) } ``` From here Veriqa takes over: the user sees the sign-in page with a QR code and the channel list, confirms the sign-in in a messenger on their phone, and the browser returns to your `redirect_uri` with a code. ## 5. Handle the return ```js callback.js theme={null} import * as client from 'openid-client' import { config } from './config.js' export async function handleCallback(req, res) { const currentUrl = new URL(req.url, process.env.APP_REDIRECT_URI) let tokens try { tokens = await client.authorizationCodeGrant(config, currentUrl, { pkceCodeVerifier: req.session.code_verifier, expectedState: req.session.state, }) } catch (err) { // The user did not go through with the sign-in: the answer carries error=access_denied // instead of a code. That is an answer, not a fault. if (err instanceof client.AuthorizationResponseError && err.error === 'access_denied') { res.redirect('/signin-cancelled') return } throw err } const claims = tokens.claims() // the parsed id_token req.session.user = { sub: claims.sub, name: claims.name, channel_type: claims.channel_type, // only with the channel scope (step 7) } res.redirect('/') } ``` `authorizationCodeGrant` exchanges the code for tokens and validates the response itself; `state` is checked through `expectedState`, so you do not verify it again by hand. The `claims()` method returns the parsed `id_token`, or `undefined` if the server issued none. The `catch` branch handles a sign-in that did not complete. The sign-in page has a [**Cancel sign-in** button](/docs/guides/customization#cancel-button), on by default; a user who clicks it comes back to `/callback` with `error=access_denied` and no code. `authorizationCodeGrant` answers an error response by throwing `AuthorizationResponseError` — without the branch the return ends in an unhandled exception instead of a page of your application. `/` and `/signin-cancelled` are where the callback finally lands the browser — the routes [step 6](#6-wire-it-into-a-server) adds. ## 6. Wire it into a server The three modules above are handlers, not an application: something has to give them `req.session`, put them on routes and listen on a port. This is that file, and it is the one you start. ```js server.js theme={null} import express from 'express' import session from 'express-session' import { startLogin } from './login.js' import { handleCallback } from './callback.js' const app = express() // express-session is what puts req.session in place — the PKCE code_verifier and the // state live there while the user is confirming on the phone. The default store keeps // sessions in the memory of one process: enough for this walkthrough, not for production. app.use(session({ secret: process.env.SESSION_SECRET, resave: false, saveUninitialized: false, })) app.get('/login', startLogin) app.get('/callback', handleCallback) app.get('/', (req, res) => { if (req.session.user === undefined) { res.redirect('/login') return } res.json(req.session.user) }) // Where the callback sends a user who did not go through with the sign-in. A route of its own // because "/" sends an anonymous visitor to /login — straight back to the page they just left. app.get('/signin-cancelled', (req, res) => { res.send('Sign-in was cancelled. Open / to sign in again.') }) app.listen(3000, '127.0.0.1') ``` The route paths are the contract of the page: `/callback` is what `APP_REDIRECT_URI` points at and what you registered in [step 2](#2-register-a-client-on-the-veriqa-side), and `/` shows what the sign-in produced — with nothing in the session yet it sends you to `/login`, and after a completed sign-in it answers with the stored claims as JSON, so the outcome is visible without a template. ## 7. Read the claims Veriqa adds the base set of claims to the tokens itself: | Claim | Meaning | Where | | - | - | - | | `sub` | Stable subject identifier | both tokens | | `auth_time` | When the confirmation completed (Unix) | `id_token` only | | `amr` | The channel type that confirmed the sign-in (`telegram`, `whatsapp`, `max`, `email`) | both tokens; an array of strings in the `id_token` (`["telegram"]`), even for a single method | | `name`, `email`, `phone_number`, … | From the resolved identity — depending on the requested scopes (`profile`, `email`, `phone`) | both tokens | | `picture` | The user's avatar — only with the `avatar` scope, also listed in `AllowedScopes` ([details](/docs/concepts/oidc-explainer#resolved-identity-and-claims)) | `access_token` only — read it from userinfo | **Channel claims are issued only with the `channel` scope:** | Claim | Meaning | | - | - | | `channel_type` | The channel type the user is linked through | | `channel_user_id` | The user identifier inside that channel | Without the requested `channel` scope these two claims end up in **no** token — and therefore do not appear in the userinfo response either, which mirrors the access token. An application uses exactly these claims to tell linked channels apart, so the scope is mandatory for account linking. ```js theme={null} const userinfo = await client.fetchUserInfo(config, tokens.access_token, claims.sub) // userinfo.channel_type / userinfo.channel_user_id — present only if the channel scope was requested ``` The third argument is the expected `sub`: the library checks it against the response and will not let the subject be swapped. ## 8. Restrict sign-in to a specific channel The standard OIDC `acr_values` parameter in the `channel:{type}` form narrows the choice on the sign-in page: ```js theme={null} const parameters = { redirect_uri: process.env.APP_REDIRECT_URI, scope: 'openid profile', acr_values: 'channel:telegram', code_challenge, code_challenge_method: 'S256', state, } ``` Several space-separated values (`channel:telegram channel:whatsapp`) let the user pick from the listed ones. Without the parameter every channel enabled on the server is available. ## 9. Checkpoint Start the application from the directory holding `server.js`, in the shell where the variables of [step 2](#2-register-a-client-on-the-veriqa-side) are exported: ```bash theme={null} node server.js ``` It serves on `http://127.0.0.1:3000` — the address `APP_REDIRECT_URI` is built from. `curl $VERIQA_ISSUER/.well-known/openid-configuration` returns metadata with `https` URLs. Open `http://127.0.0.1:3000/login` — the browser should land on the Veriqa page with the QR code and the channel list. Confirm the request in a trusted channel; the browser returns to `/callback`, which redirects it to `/`. `/` answers with the session as JSON: `sub` is filled in, and `channel_type` carries the channel that confirmed the sign-in — it is absent if the `channel` scope was not requested. ## Next steps Channel linking, re-confirmation and action approval — at the HTTP level. Deploy your own issuer in Docker. The same issuer without Docker — systemd or a Windows service, from an archive. Where Veriqa fits into the standard flow. What to check before shipping. # Install as an OS service Source: https://veriqa.app/docs/quickstart/os-service Install the Veriqa auth server from an archive as a systemd unit or a Windows service — no Docker, no .NET on the machine. This is the same standalone auth server as the [self-hosted quickstart](/docs/quickstart/self-hosted), delivered without Docker: an archive with a **self-contained** executable plus an installer that registers it as a systemd unit or a Windows service. The target machine needs no .NET runtime. What this page covers is the **installation** — where the files live, how the service is registered, how it is updated and removed. What to put into the configuration (stores, certificates, clients, channels) does not change between delivery modes and is covered in the [self-hosted quickstart](/docs/quickstart/self-hosted). **The installer leaves you with a running service, not with a production one.** It writes a startup configuration — an in-memory store and two self-signed token certificates — so that the service answers `/health/live` straight away instead of refusing to start. [Step 6](#6-move-to-production) covers everything that makes it a production instance. ## 1. Download the archive and check it A release carries three archives and one checksum file: | File | What it is | | - | - | | `veriqa-authserver--linux-x64.tar.gz` | Linux, x86-64 | | `veriqa-authserver--linux-arm64.tar.gz` | Linux, ARM64 | | `veriqa-authserver--win-x64.zip` | Windows, x86-64 | | `veriqa-authserver--SHA256SUMS.txt` | SHA-256 sums of the three archives | They are release assets, and the base of their address is stable — `permalink/latest` always resolves to the newest release: ``` /-/releases/permalink/latest/downloads/veriqa-authserver--linux-x64.tar.gz ``` The file name carries the version, so the address as a whole is **not** a permanent link: once the next release is out, a path with the previous version is no longer among the assets `permalink/latest` resolves to, and the download answers 404. Take the repository address and the current version from [veriqa.app/source](https://veriqa.app/source) each time instead of keeping a ready-made URL: ```bash theme={null} VERIQA_VERSION=0.6.0 # version of the release you are installing VERIQA_DOWNLOADS=… # …/-/releases/permalink/latest/downloads, from veriqa.app/source curl -fLO "$VERIQA_DOWNLOADS/veriqa-authserver-$VERIQA_VERSION-linux-x64.tar.gz" curl -fLO "$VERIQA_DOWNLOADS/veriqa-authserver-$VERIQA_VERSION-SHA256SUMS.txt" sha256sum --ignore-missing -c "veriqa-authserver-$VERIQA_VERSION-SHA256SUMS.txt" ``` `--ignore-missing` is what makes this work with a single archive downloaded: the sums file lists all three, and without the flag `sha256sum` fails on the two you did not download. On Windows: ```powershell theme={null} $version = "0.6.0" $expected = (Select-String -Path ".\veriqa-authserver-$version-SHA256SUMS.txt" -Pattern "win-x64\.zip").Line.Split(" ")[0] (Get-FileHash ".\veriqa-authserver-$version-win-x64.zip" -Algorithm SHA256).Hash -eq $expected ``` `True` means the archive is intact. ## 2. Install on Linux The archive unpacks into a single `veriqa-authserver/` directory and carries its own installer, `install.sh`. Run it as root **from that directory**: ```bash theme={null} tar -xzf "veriqa-authserver-$VERIQA_VERSION-linux-x64.tar.gz" cd veriqa-authserver sudo ./install.sh ``` The installer creates the system user `veriqa` (no login shell, no home directory created), copies the program, creates the configuration and data directories, generates the token certificates, writes the startup configuration, installs the unit file and enables the service. It does **not** start it: the startup configuration is meant to be read before the first start, not after it. | Option | Default | What it sets | | - | - | - | | `--install-directory ` | `/opt/veriqa` | Program directory | | `--config-directory ` | `/etc/veriqa` | Configuration directory | | `--urls ` | `http://127.0.0.1:8080` | Addresses the service listens on | | `--service-name ` | `veriqa` | Name of the systemd unit | **The program directory belongs to this installation alone.** Both installers write into it without clearing it out first — a file of the archive overwrites its counterpart, anything else stays where it is — and removal is the mirror image: `--uninstall` / `-Uninstall` takes away the files the installer copied and nothing else, and keeps the directory itself if something else is still in there. Point `--install-directory` / `-InstallDirectory` at a directory of its own and the installation comes off in one step. The default `--urls` is **loopback only** — the instance is reachable from the same machine and from a reverse proxy on it, and from nowhere else. **TLS is needed for the API itself, not only to open the instance up.** Over plain HTTP the token endpoint answers `400 invalid_request` — "This server only accepts HTTPS requests" ([ID2083](https://documentation.openiddict.com/errors/ID2083)) — so a backend calling the confirmation API gets no token, even from the same machine. Do [step 7](#7-https) before the first call: put a reverse proxy in front, or give Kestrel a certificate. Start the service when you have read what the installer printed: ```bash theme={null} sudo systemctl start veriqa systemctl status veriqa ``` The installer refuses to run over a service that is already registered under the same name (`use --uninstall first`) rather than recreate it silently. Updating an existing installation is covered in [Updating](#updating). ## 3. Install on Windows Unpack the `.zip` and run `install.ps1` from the unpacked directory in an **elevated** PowerShell (Windows PowerShell 5.1 and PowerShell 7 both work): ```powershell theme={null} Expand-Archive ".\veriqa-authserver-$version-win-x64.zip" -DestinationPath . cd .\veriqa-authserver .\install.ps1 ``` | Parameter | Default | What it sets | | - | - | - | | `-InstallDirectory ` | `%ProgramFiles%\Veriqa` | Program directory | | `-Urls ` | `http://127.0.0.1:8080` | Addresses the service listens on | | `-ServiceName ` | `Veriqa` | Name of the Windows service | The service is registered with automatic start under the virtual account `NT SERVICE\` — no account and no password of your own. The configuration directory is `%ProgramData%\Veriqa`; its access list is rebuilt without inheritance, so the `Users` group gets no access to the secrets kept there. As on Linux, the service is registered but left stopped: ```powershell theme={null} Start-Service Veriqa Get-Service Veriqa ``` ## 4. What the first start gives you The installation is deliberately complete enough to start and no more: * the OpenIddict store is `InMemory` — registered clients, issued tokens and the journal are **lost on every restart**; * the token certificates are self-signed, RSA-2048, valid for two years, and were generated on this machine; * no channel and no OIDC client is configured, so a real sign-in cannot complete yet. Both health endpoints answer 200 in this state: ```bash theme={null} curl -f http://127.0.0.1:8080/health/live curl -f http://127.0.0.1:8080/health/ready ``` `/health/ready` is 200 because the startup configuration declares no external dependency to check — not because the instance is ready for production. ## 5. Where the files are The configuration, the data-protection keys and the token certificates live **outside** the program directory, so that replacing the program never touches them. | What | Linux | Windows | | - | - | - | | Program | `/opt/veriqa` | `%ProgramFiles%\Veriqa` | | Configuration | `/etc/veriqa/appsettings.Production.json` | `%ProgramData%\Veriqa\appsettings.Production.json` | | Sample production configuration | `/opt/veriqa/appsettings.Production.sample.json` | `%ProgramFiles%\Veriqa\appsettings.Production.sample.json` | | Data-protection keys | `/var/lib/veriqa/keys` | `%ProgramData%\Veriqa\keys` | | Token certificates | `/var/lib/veriqa/certs` | `%ProgramData%\Veriqa\certs` | | Service definition | `/etc/systemd/system/veriqa.service` | Registry key of the `Veriqa` service | On Linux `/etc/veriqa` is `root:veriqa` `0750` and `/var/lib/veriqa` is `0700` owned by `veriqa`: they hold secrets, and only the service account reads them. **Why the keys directory is not optional.** Data-protection keys protect the sign-in cookies and every value the host encrypts. With no keys directory and no Redis the host keeps them in memory, and every restart invalidates what the previous process issued — users are thrown out of sign-ins that were in flight. The installer therefore sets `Veriqa:DataProtection:KeysDirectory` in the environment of the service itself, and the directory survives an update and an ordinary uninstall. The service is registered with four environment variables — the environment is where they belong, because they say where the configuration is read from: | Variable | Linux | Windows | | - | - | - | | `ASPNETCORE_ENVIRONMENT` | `Production` | `Production` | | `ASPNETCORE_URLS` | value of `--urls` | value of `-Urls` | | `VERIQA_CONFIG_DIRECTORY` | `/etc/veriqa` | `%ProgramData%\Veriqa` | | `Veriqa__DataProtection__KeysDirectory` | `/var/lib/veriqa/keys` | `%ProgramData%\Veriqa\keys` | ### The configuration directory `VERIQA_CONFIG_DIRECTORY` names a directory, not a file. From it the host reads `appsettings.json` and `appsettings.Production.json`, both optional, and layers them **over** the files shipped with the program. Precedence, weakest to strongest: 1. `appsettings.json` of the program directory; 2. `appsettings.Production.json` of the program directory; 3. `appsettings.json` of the configuration directory; 4. `appsettings.Production.json` of the configuration directory; 5. environment variables (`Veriqa__OpenIddict__…`); 6. command-line arguments. That is why the one line the installer writes — `Provider` = `InMemory` — wins over the `Provider` = `PostgreSQL` that ships in the program directory, and why anything you pass through the environment still wins over both. If the variable names a directory that does not exist, the host refuses to start and says so: `VERIQA_CONFIG_DIRECTORY points to '/etc/veriqa', which does not exist.` A directory that exists but holds no files is not an error. The self-hosted quickstart keeps the integrator's own settings in `config/veriqa.json`, resolved against the content root. For a service the content root is the **program** directory, which an update replaces — so in this delivery mode put your settings into the configuration directory instead. ## 6. Move to production The installation ships a template next to the program: `appsettings.Production.sample.json`. It carries the same keys as the self-hosted quickstart, with `REPLACE_ME` placeholders for the values. Use it as a **reference** and edit the configuration file the installer wrote, rather than overwriting that file with it: the template carries no paths to the certificates generated on this machine, and every `REPLACE_ME` in it is a value you still have to supply. ```bash theme={null} sudo cp /etc/veriqa/appsettings.Production.json /root/appsettings.Production.json.bak sudo nano /etc/veriqa/appsettings.Production.json # reference: /opt/veriqa/appsettings.Production.sample.json sudo systemctl restart veriqa ``` Keep that copy **outside** `/etc/veriqa`: from the edit below on it holds secrets — a bot token, connection strings, certificate passwords — and [full removal](#removing) neither takes it away on Linux nor spares it on Windows, where `%ProgramData%\Veriqa` is deleted entirely. What to write there is documented once, for both delivery modes: * **stores** — a relational provider and its connection string, and the audit sink: [self-hosted, step 2](/docs/quickstart/self-hosted#2-configure-it); * **token certificates** — real ones instead of the self-signed pair the installer generated: [self-hosted, step 3](/docs/quickstart/self-hosted#3-provide-the-token-certificates); * **OIDC clients** — [self-hosted, step 4](/docs/quickstart/self-hosted#4-declare-your-client); * **channels** — [self-hosted, step 7](/docs/quickstart/self-hosted#7-enable-channels), and [Channel setup](/docs/guides/channels) end to end. Replacing the token certificates invalidates every token signed with the old pair, and switching the store from `InMemory` to a database starts from an empty one: the clients registered in memory have to be declared in the configuration. Do both before the instance carries real sign-ins. ### SQL Server on Windows The Windows archive does not include `Microsoft.Data.SqlClient.SNI.dll`, the native network library the SQL Server driver uses on Windows: it is Microsoft's, under the Microsoft Software License Terms, and Veriqa does not redistribute it. PostgreSQL needs nothing, and neither does Linux. With `SqlServer` selected for any store and the library missing, the service refuses to start, and the message names the `Microsoft.Data.SqlClient` version and links its nuget.org page. Take the library from the NuGet package `Microsoft.Data.SqlClient.SNI.runtime`, in the version listed under **Dependencies** on that page, and put it next to the program. By downloading it, you accept Microsoft's terms for it: ```powershell theme={null} $sniVersion = "…" # from the Dependencies of Microsoft.Data.SqlClient on nuget.org $package = "microsoft.data.sqlclient.sni.runtime" Invoke-WebRequest "https://api.nuget.org/v3-flatcontainer/$package/$sniVersion/$package.$sniVersion.nupkg" -OutFile "$env:TEMP\sni.zip" Expand-Archive "$env:TEMP\sni.zip" -DestinationPath "$env:TEMP\sni" -Force Copy-Item "$env:TEMP\sni\runtimes\win-x64\native\Microsoft.Data.SqlClient.SNI.dll" "$env:ProgramFiles\Veriqa\" Restart-Service Veriqa ``` The installer removes only the files it put in the program directory, so the library stays there through an [update](#updating); after one, check that the version the new release's driver lists is still the one you copied. On [full removal](#removing) delete it by hand. ## 7. HTTPS The host speaks HTTP on the addresses of `--urls` / `-Urls`, and OpenIddict serves `/connect/token` and the rest of the protocol **only over `https`** — so this step is not optional, whether or not the instance is reachable from outside. There are two ways to put TLS in front of it. **(a) A reverse proxy on the same machine.** Leave `--urls` at `http://127.0.0.1:8080`, terminate TLS in NGINX / Caddy / IIS and forward to loopback. For OpenIddict to build `https` URLs the proxy must forward `X-Forwarded-Proto` and `X-Forwarded-For`, and the proxy must be trusted — the same `ForwardedHeaders` section as in [self-hosted, step 6](/docs/quickstart/self-hosted#6-put-it-behind-a-reverse-proxy), in the configuration file of the installation: ```json appsettings.Production.json theme={null} { "ForwardedHeaders": { "KnownProxies": [ "127.0.0.1" ] } } ``` Only loopback is trusted by default, so a proxy on the same machine is covered even without this section — writing it out keeps the trust boundary visible in the configuration. A proxy on **another** host is not covered: put its address here instead, or its network in `KnownIPNetworks`. The proxy is also where per-IP rate limiting belongs — see [Production hardening](/docs/guides/hardening#rate-limiting-and-anti-abuse). [Behind a reverse proxy](/docs/guides/reverse-proxy) covers the paths it has to pass and the WebSocket of the sign-in page. **(b) A certificate in Kestrel**, with no proxy at all. This is plain ASP.NET Core configuration in the same file ([Kestrel endpoints](https://learn.microsoft.com/aspnet/core/fundamentals/servers/kestrel/endpoints)): ```json appsettings.Production.json theme={null} { "Kestrel": { "Endpoints": { "Https": { "Url": "https://0.0.0.0:8443", "Certificate": { "Path": "/var/lib/veriqa/certs/server.pfx", "Password": "…" } } } } } ``` The `Kestrel:Endpoints` section **replaces** the addresses of `ASPNETCORE_URLS` rather than adding to them, so no reinstall is needed — but the HTTP endpoint the installer set through `--urls` is gone with it. Declare an `Http` endpoint alongside if you still want one (for local health probes, for instance). Keep the server certificate readable by the service account only, next to the token certificates. ## 8. Verify ```bash theme={null} curl -f http://127.0.0.1:8080/health/live # the process is up curl -f http://127.0.0.1:8080/health/ready # configured dependencies answer curl https://auth.your-domain.com/.well-known/openid-configuration ``` Discovery must come back with `https` URLs; if it comes back with `http`, the proxy is not trusted — [step 7](#7-https). The full checklist of a deployed instance, including the first sign-in, is in [self-hosted, step 8](/docs/quickstart/self-hosted#8-verify). On Linux the service log is in the journal: ```bash theme={null} journalctl -u veriqa -n 200 --no-pager ``` On Windows a failed start is recorded by the Service Control Manager in Event Viewer → Windows Logs. To see the message of the host itself, run the executable in a console **from the program directory**, with the same environment: ```powershell theme={null} $env:ASPNETCORE_ENVIRONMENT = "Production" $env:VERIQA_CONFIG_DIRECTORY = "$env:ProgramData\Veriqa" # Started by hand, the content root is the working directory — `appsettings*.json` and `wwwroot` # are read from there, so step into the program directory instead of running it by full path. Set-Location "$env:ProgramFiles\Veriqa" .\Veriqa.Core.AuthServer.Host.exe ``` ## Updating An update replaces the **program** and keeps the configuration, the data-protection keys and the token certificates: they live outside the program directory on purpose. ```bash theme={null} sudo ./install.sh --uninstall # from the unpacked archive of the installed version tar -xzf "veriqa-authserver-$VERIQA_VERSION-linux-x64.tar.gz" # the new version cd veriqa-authserver && sudo ./install.sh sudo systemctl start veriqa ``` On Windows the same two steps: `.\install.ps1 -Uninstall` from the unpacked archive of the installed version, then `.\install.ps1` from the new archive, then `Start-Service Veriqa`. The second run does not overwrite an existing `appsettings.Production.json` and does not regenerate existing certificates — it reports that it kept them. If you installed with non-default `--service-name`, `--install-directory` or `--config-directory`, pass the same values to both runs. `--urls` / `-Urls` has to be repeated on the installing run as well, and for a different reason: it is not kept anywhere to be read back. The installer writes the unit file — on Windows, the environment of the service — anew every time, so an install without it puts the service back on the default `http://127.0.0.1:8080` and the instance stops answering anything but loopback. ## Removing **Ordinary removal** takes away the service and the program and **keeps** the configuration, the data-protection keys and the certificates, so that reinstalling over them keeps current sign-ins and secrets: ```bash theme={null} sudo ./install.sh --uninstall ``` ```powershell theme={null} .\install.ps1 -Uninstall ``` Run it **from the unpacked archive of the installed version**: the installer reads that archive to know which files under the program directory are its own, and takes away those. The program directory goes with them once it is empty; if anything else is left in it, the directory stays, and the installer says so instead of clearing it out. Without the archive next to it the installer removes the service and warns that the program files could not be told apart — it does not guess. **Full removal** also deletes the configuration the installer wrote, the data directory and (on Linux) the `veriqa` user: ```bash theme={null} sudo ./install.sh --uninstall --remove-data ``` ```powershell theme={null} .\install.ps1 -Uninstall -RemoveData ``` Full removal makes **every issued cookie invalid** — the data-protection keys are gone with the directory — and the tokens signed with the removed certificates stop being accepted. Copy `/etc/veriqa/appsettings.Production.json` (or `%ProgramData%\Veriqa\appsettings.Production.json`) somewhere safe first if this installation is meant to come back. **Somewhere safe means outside those directories**: a copy left next to the configuration is not a backup — on Windows it goes with `%ProgramData%\Veriqa`, and on Linux it stays behind, secrets and all, because the directory survives and the installer does not name what is left in it. On Linux `/var/lib/veriqa` is deleted entirely, while `/etc/veriqa` loses the `appsettings.Production.json` the installer wrote and is removed only when nothing else is left in it. On Windows all three live in `%ProgramData%\Veriqa`, which is deleted entirely. `--remove-data` / `-RemoveData` without the uninstall switch is refused, and nothing is changed. Removing twice is safe: with no service left, the installer says so and takes away what is left of the installation. ## Common failures | Symptom | Cause | What to do | | - | - | - | | A backend gets `400 invalid_request`, "This server only accepts HTTPS requests", from `/connect/token` | The instance is called over plain HTTP: OpenIddict serves the protocol over `https` only | Put TLS in front of it, or make the proxy trusted so that `X-Forwarded-Proto` is read — [step 7](#7-https) | | Windows: the service does not start, the log says `Microsoft.Data.SqlClient.SNI.dll` is not found | `SqlServer` is selected for a store, and the archive does not ship Microsoft's native library for it | Put the library next to the program — [SQL Server on Windows](#sql-server-on-windows) | | After a restart the registered clients and the issued tokens are gone | The startup configuration is still in place: `Veriqa:OpenIddict:Database:Provider` = `InMemory` is the volatile store | Move to a relational provider — [step 6](#6-move-to-production) and [self-hosted, step 2](/docs/quickstart/self-hosted#2-configure-it) | | The service is active, but every restart throws users out of their sign-ins | The host keeps the data-protection keys in memory: `Veriqa:DataProtection:KeysDirectory` is not in effect (the environment of the service was edited, or the host runs outside the service manager) | Check the four variables of [step 5](#5-where-the-files-are); `systemctl show veriqa -p Environment` on Linux | | The sign-in page shows untranslated texts | The content root is not the program directory. Under a service manager it always is; started by hand from another directory it is the working directory, and `wwwroot/locales` is read relative to the content root | Start the service through `systemctl` / `Start-Service`, or `cd` into the program directory first | | Discovery returns `http` URLs, `/connect/authorize` answers `invalid_request` | The proxy is not trusted, so `X-Forwarded-Proto` is ignored | Add the proxy to `ForwardedHeaders:KnownProxies` — [step 7](#7-https) | | After an update the instance answers on loopback only, though the service is active | The installing run was made without `--urls` / `-Urls`: the unit file (on Windows, the environment of the service) was written anew with the default `http://127.0.0.1:8080` | Reinstall with the address in use: `./install.sh --uninstall`, then `./install.sh --urls ` from the archive — [Updating](#updating) | | `Access denied` on the keys directory | The directory was created by someone other than the service account — by hand as root, or under a different `--service-name` / `-ServiceName`, whose virtual account is a different one on Windows | Linux: `chown -R veriqa:veriqa /var/lib/veriqa`. Windows: `.\install.ps1 -Uninstall -ServiceName `, then `.\install.ps1 -ServiceName ` from the archive — both runs need the name the service was installed under (and the same `-InstallDirectory`, if the installation used one), otherwise they act on the default `Veriqa` service and `%ProgramFiles%\Veriqa` instead. The reinstall rebuilds the access lists and keeps the configuration, the keys and the certificates (over a registered service the installer refuses to run) | Failures that stop the host **at startup** — a missing certificate, an incomplete client, a channel without a token, an unknown provider value — are the same in both delivery modes and are listed in [self-hosted, step 9](/docs/quickstart/self-hosted#9-common-first-run-failures). On Linux the message is in `journalctl -u veriqa`. ## Building the archives yourself For the release archives the repository carries a pair of scripts that behave identically — `build/publish-host.sh` and `build/publish-host.ps1`: ```bash theme={null} ./build/publish-host.sh # all three RIDs, Release ./build/publish-host.sh --rid linux-x64 # one of them ``` They publish the host self-contained and single-file for `linux-x64`, `linux-arm64` and `win-x64`, pack each result together with the installer files of its OS, and leave the archives and `veriqa-authserver--SHA256SUMS.txt` in `artifacts/host/`. The version comes from `build/Versions.props`. What comes out is what this page installs — the same layout, the same `appsettings.Production.sample.json`, and no `appsettings.Development.json`. ## Next steps Stores, certificates, clients and channels — the configuration itself. Telegram, WhatsApp, other channels and Email — end to end. Every section, key and default in one place. What to check before shipping. The paths to pass, the WebSocket and an NGINX configuration. Ask for what the channel did not give, inside the same sign-in. # Python quickstart Source: https://veriqa.app/docs/quickstart/python Connecting a Python application to Veriqa over standard OpenID Connect — cloud, Docker or an OS service. For non-.NET stacks Veriqa is an ordinary OpenID Connect provider. There is no Veriqa package for Python, and none is needed: you plug in a standard OIDC client, and Veriqa handles all the specifics (sign-in page, QR code, confirmation in the messenger) on its own side. **One codebase, any deployment.** The application never depends on **where** Veriqa runs — Veriqa Cloud, a Docker container of your own, or a systemd unit / Windows service on a machine of your own. The deployments differ **only in the issuer address** and in where the client is registered; the code below is identical for all of them, and switching comes down to a single `server_metadata_url`. ## 1. Install the library The recommended library is [Authlib](https://docs.authlib.org/) (current line **1.8.x**): ```bash theme={null} pip install authlib flask requests ``` Authlib pulls in neither the web framework nor an HTTP client. `flask` is the framework the example below is built on; `requests` is what Authlib's Flask and Django integrations are built on — the Starlette and FastAPI ones use `httpx` instead, so install the one your framework needs. Without either package, the very first run fails on the import block at the top of `app.py` with `ModuleNotFoundError`, before the application reaches Veriqa at all. **Python 3.10 or newer** is required — that is the minimum declared by the package. Ready-made integrations exist for Flask, Django, Starlette and FastAPI; Flask is shown below, the others use the same `OAuth` registry and the same calls. ## 2. Register a client on the Veriqa side **Veriqa Cloud is currently unavailable** — the cloud rows describe its API contract. Use self-hosted; details are in [Veriqa Cloud](/docs/cloud/overview). | Mode | Where the client is registered | What you get | | - | - | - | | Cloud | The **Applications / OIDC clients** section of the console. A default application already exists alongside the project — a **public client with PKCE, no secret and no `redirect_uri`**, so adding your `redirect_uri` comes first. The `issuer` address is on the Project settings screen, read-only | `issuer`, `client_id`, `redirect_uri` | | Self-hosted | The `Veriqa:OpenIddict:Clients` section of the server configuration — see [step 4 of the self-hosted quickstart](/docs/quickstart/self-hosted#4-declare-your-client). The same section whichever way the server is delivered | The same, with values you choose | **Self-hosted comes in two deliveries, and this page is the same for both.** The server runs in a Docker container — [self-hosted quickstart](/docs/quickstart/self-hosted) — or is installed from an archive as a systemd unit or a Windows service, with no Docker and no .NET runtime on the machine — [Install as an OS service](/docs/quickstart/os-service). The choice decides how the server is delivered, not the client registration, the issuer address or any of the code below. **A secret is not mandatory.** The minimal working set is `issuer`, `client_id`, `redirect_uri`: a public client with PKCE, and that is the cloud default. A confidential client is only needed when you want to authenticate a server-side application with a secret; in the cloud the secret is issued by a separate explicit action on the Applications screen and is shown **once**. The `redirect_uri` is matched **exactly** — wildcards are not supported. Register precisely the URL your callback route serves. For the Flask example below, started as in [step 7](#7-checkpoint), that URL is `http://127.0.0.1:5000/authorize` — register exactly that URL, and open the application at the same host (`127.0.0.1`, not `localhost`): the callback URL is built by `url_for(..., _external=True)` from the host of the incoming request, and the comparison on the Veriqa side is literal. ## 3. Register the provider `server_metadata_url` turns on discovery: Authlib reads `/.well-known/openid-configuration` itself and finds the remaining endpoints. The code takes its values from the environment, so set them in the shell you will start the application from. The first three are what step 2 gave you; the fourth is yours: ```bash theme={null} export VERIQA_ISSUER="https://veriqa.your-domain.com" # the issuer address export VERIQA_CLIENT_ID="my-app" # the client you registered export VERIQA_CLIENT_SECRET="…" # confidential client only export FLASK_SECRET_KEY="$(python -c 'import secrets; print(secrets.token_hex())')" ``` `FLASK_SECRET_KEY` is not issued by Veriqa — it is a random string of your own, the key Flask signs the session cookie with. Keep it out of version control and use a separate value in every environment. The public variant needs no `VERIQA_CLIENT_SECRET`: a public client has no secret at all. If a variable your variant reads is missing, the very first run fails with `KeyError` while `app.py` is still being imported, before any route is served. Each variant below is the complete top of `app.py`: the Flask application, the session secret Authlib needs, and the provider registration. The routes of [step 4](#4-define-the-sign-in-routes) go into the same file, below it. ```python app.py — public client (PKCE) theme={null} import os from authlib.integrations.flask_client import OAuth, OAuthError from flask import Flask, redirect, session, url_for app = Flask(__name__) # Authlib keeps the OAuth state and the PKCE code_verifier in the Flask session, # so a secret key is required. app.secret_key = os.environ["FLASK_SECRET_KEY"] oauth = OAuth(app) oauth.register( name="veriqa", client_id=os.environ["VERIQA_CLIENT_ID"], server_metadata_url=f"{os.environ['VERIQA_ISSUER']}/.well-known/openid-configuration", client_kwargs={ "scope": "openid profile channel", "code_challenge_method": "S256", # enables PKCE — must be set explicitly }, ) ``` ```python app.py — confidential client theme={null} import os from authlib.integrations.flask_client import OAuth, OAuthError from flask import Flask, redirect, session, url_for app = Flask(__name__) # Authlib keeps the OAuth state in the Flask session, so a secret key is required. app.secret_key = os.environ["FLASK_SECRET_KEY"] oauth = OAuth(app) oauth.register( name="veriqa", client_id=os.environ["VERIQA_CLIENT_ID"], client_secret=os.environ["VERIQA_CLIENT_SECRET"], server_metadata_url=f"{os.environ['VERIQA_ISSUER']}/.well-known/openid-configuration", client_kwargs={"scope": "openid profile channel"}, ) ``` **PKCE in Authlib does not turn itself on — unlike the authentication method.** The client authentication method is derived automatically: with a `client_secret` it is `client_secret_basic`, without one it is `none`, so a public client is simply the absence of a secret. PKCE, however, is an independent switch: with no `code_challenge_method` in `client_kwargs` a public client will run **without** PKCE. Set it explicitly. Switching between deployments — cloud, Docker, OS service — comes down to changing `VERIQA_ISSUER` alone. ## 4. Define the sign-in routes ```python app.py theme={null} @app.route("/") def index(): user = session.get("user") if user is None: return redirect(url_for("login")) return user @app.route("/login") def login(): redirect_uri = url_for("authorize", _external=True) return oauth.veriqa.authorize_redirect(redirect_uri) @app.route("/authorize") def authorize(): try: token = oauth.veriqa.authorize_access_token() except OAuthError as error: # The user did not go through with the sign-in: the answer carries error=access_denied # instead of a code. That is an answer, not a fault. if error.error == "access_denied": return redirect(url_for("signin_cancelled")) raise userinfo = token["userinfo"] session["user"] = { "sub": userinfo["sub"], "name": userinfo.get("name"), "channel_type": userinfo.get("channel_type"), } return redirect("/") # Where the callback sends a user who did not go through with the sign-in. A route of its own # because "/" sends an anonymous visitor to /login — straight back to the page they just left. @app.route("/signin-cancelled") def signin_cancelled(): return "Sign-in was cancelled. Open / to sign in again." ``` `authorize_access_token()` exchanges the code for tokens and parses the `id_token` itself — the parsed claims are in `token["userinfo"]`, with no separate call to the userinfo endpoint. The PKCE `code_verifier` is generated and stored by the library, so you never pass it by hand. The `except` branch handles a sign-in that did not complete. The sign-in page has a [**Cancel sign-in** button](/docs/guides/customization#cancel-button), on by default; a user who clicks it comes back to `/authorize` with `error=access_denied` and no code. `authorize_access_token()` answers an error response by raising `OAuthError` — without the branch the return ends in an HTTP 500 instead of a page of your application. Between `/login` and `/authorize` Veriqa does its work: the user sees the sign-in page with a QR code and the channel list and confirms the sign-in in a messenger on their phone. `/` is where the callback finally lands the browser: with nothing in the session yet it sends you to `/login`, and after a completed sign-in it shows what was stored — a dict returned from a Flask view is serialized to JSON, so the outcome is visible without a template. ## 5. Read the claims Veriqa adds the base set of claims to the tokens itself: | Claim | Meaning | Where | | - | - | - | | `sub` | Stable subject identifier | both tokens | | `auth_time` | When the confirmation completed (Unix) | `id_token` only | | `amr` | The channel type that confirmed the sign-in (`telegram`, `whatsapp`, `max`, `email`) | both tokens; an array of strings in the `id_token` (`["telegram"]`), even for a single method | | `name`, `email`, `phone_number`, … | From the resolved identity — depending on the requested scopes | both tokens | | `picture` | The user's avatar — only with the `avatar` scope, also listed in `AllowedScopes` ([details](/docs/concepts/oidc-explainer#resolved-identity-and-claims)) | `access_token` only — read it from userinfo | **Channel claims are issued only with the `channel` scope:** | Claim | Meaning | | - | - | | `channel_type` | The channel type the user is linked through | | `channel_user_id` | The user identifier inside that channel | Without the requested `channel` scope these two claims end up in **no** token — and therefore do not appear in the userinfo response either, which mirrors the access token. An application uses exactly these claims to tell linked channels apart, so the scope is mandatory for account linking. ```python theme={null} channel_type = token["userinfo"].get("channel_type") # None if the channel scope was not requested ``` ## 6. Restrict sign-in to a specific channel The standard OIDC `acr_values` parameter in the `channel:{type}` form narrows the choice on the sign-in page. Extra keyword arguments to `authorize_redirect` go into the authorize request as-is: ```python theme={null} return oauth.veriqa.authorize_redirect( redirect_uri, acr_values="channel:telegram", ) ``` Several space-separated values (`channel:telegram channel:whatsapp`) let the user pick from the listed ones. Without the parameter every channel enabled on the server is available. ## 7. Checkpoint Start the application from the directory holding `app.py`, in the shell where the variables of [step 3](#3-register-the-provider) are exported: ```bash theme={null} flask --app app run ``` Flask serves it on `http://127.0.0.1:5000` — that is the address the `redirect_uri` registered in [step 2](#2-register-a-client-on-the-veriqa-side) is built from. `curl $VERIQA_ISSUER/.well-known/openid-configuration` returns metadata with `https` URLs. Open `http://127.0.0.1:5000/login` — the browser should land on the Veriqa page with the QR code and the channel list. Confirm the request in a trusted channel; the browser returns to your `/authorize` route, which redirects it to `/`. `/` shows the session as JSON: `sub` is filled in, and `channel_type` carries the channel that confirmed the sign-in — it stays `null` if the `channel` scope was not requested. ## Next steps Channel linking, re-confirmation and action approval — at the HTTP level. Deploy your own issuer in Docker. The same issuer without Docker — systemd or a Windows service, from an archive. Where Veriqa fits into the standard flow. What to check before shipping. # Quickstart self-hosted Source: https://veriqa.app/docs/quickstart/self-hosted Run Veriqa as a standalone OIDC auth server in Docker — your application stays a plain OIDC client. In self-hosted mode Veriqa runs as its **own process**: a standalone OIDC auth server in a container (OpenIddict inside, though your application never needs to know that). Your application talks to it over standard OpenID Connect and does not reference a single Veriqa package. **Compliance-friendly path.** Because your application is a plain OIDC client, no Veriqa component enters your build or your SBOM. If your organization runs license scanners over dependencies, this is the mode to pick — you integrate over a protocol, not over a package. The embedded alternative is the [.NET quickstart](/docs/quickstart/dotnet). ## 1. Get the image Every release is published to the container registry of the public repository: ```bash theme={null} docker pull registry.gitlab.com/veriqa/veriqa ``` Without a tag Docker takes `latest` — the newest release. To pin a release in production, add its version as the tag (`registry.gitlab.com/veriqa/veriqa:`). The published image is `linux/amd64`. On another architecture, build it from the repository root — `build/docker/Dockerfile` publishes the same standalone host — and use your local tag in place of the image name in the commands below: ```bash theme={null} docker build -f build/docker/Dockerfile -t veriqa-authserver . ``` The image is multi-stage (SDK build → ASP.NET runtime), runs as a **non-root** user (UID `1654`), exposes port **8080** over HTTP and ships a `HEALTHCHECK` against `/health/live`. TLS is expected to be terminated in front of it — see [step 6](#6-put-it-behind-a-reverse-proxy). ## 2. Configure it The container reads the same configuration as any ASP.NET Core app, so every setting can be supplied as an environment variable. Nested keys use a **double underscore** as the separator: `Veriqa:OpenIddict:Database:Provider` becomes `Veriqa__OpenIddict__Database__Provider`. ```bash docker run theme={null} docker run -d --name veriqa \ -p 8080:8080 \ -e ASPNETCORE_ENVIRONMENT=Production \ -e Veriqa__OpenIddict__Database__Provider=PostgreSQL \ -e Veriqa__OpenIddict__Database__ConnectionString="Host=postgres;Database=veriqa;Username=veriqa;Password=…" \ -e Veriqa__TransactionEngine__Store__Provider=PostgreSQL \ -e Veriqa__TransactionEngine__Store__ConnectionString="Host=postgres;Database=veriqa;Username=veriqa;Password=…" \ -e Veriqa__Channels__Telegram__Enabled=true \ -e Veriqa__Channels__Telegram__BotToken="YOUR_BOT_TOKEN" \ -e Veriqa__Channels__Telegram__WebhookBaseUrl="https://auth.your-domain.com" \ -e Veriqa__Channels__Telegram__WebhookSecretToken="YOUR_SECRET" \ -e Veriqa__Channels__Telegram__InboundVerification=shared_secret \ registry.gitlab.com/veriqa/veriqa ``` Secrets on the command line land in your shell history and in `docker inspect`. Use an `--env-file`, Docker/Kubernetes secrets or your secret manager instead — the sample above is spelled out only to show the key names. The stores are configured independently and may point at the same database: * **`Veriqa:OpenIddict:Database`** — OIDC clients, authorizations and tokens. **There is no default:** a container with an empty section fails to start — the startup check refuses and names the fix (in `Development` the same text is logged as a `Warning`). Set `Provider` to `PostgreSQL` or `SqlServer` and supply `ConnectionString` for anything real; `Provider` = `InMemory` is the explicit choice of the volatile store: every restart drops issued tokens — fine for development and for a [single-instance connector](/docs/guides/storage#single-instance-connector-without-a-database). * **`Veriqa:TransactionEngine:Store`** — the sign-in transactions themselves. Setting `ConnectionString` switches it to a durable store; leaving it empty keeps the in-memory store, whatever `Provider` says. `Provider` is `PostgreSQL` (also what an empty key means) or `SqlServer` for EF Core, or `Redis` — then `ConnectionString` is a Redis connection string and `KeyPrefix` (default `veriqa:tx:`) prefixes the keys. Redis keeps no [collected claims store](/docs/guides/storage#collected-claims-store). * **`Veriqa:Logging:Store`** — the audit trail, optional: by default it shares the transaction database. A transaction store on `Redis` has no database to share, so the trail then needs a connection string of its own. See [Audit sink](#audit-sink). Provider values are case-insensitive. For SQL Server the connection string takes the usual SqlClient form: `Server=sqlserver;Database=veriqa;User Id=veriqa;Password=…`. The resolvers are **fail-fast**: an unrecognized provider value, or a relational provider without a connection string, stops the container at startup instead of silently falling back. ### Audit sink Audit records are written only when `Veriqa:Logging:Mode` is `Audit`. Two optional keys decide where they go, and each falls back on its own: | Key | When unset | | - | - | | `Veriqa:Logging:Store:ConnectionString` | `Veriqa:TransactionEngine:Store:ConnectionString` is used | | `Veriqa:Logging:Store:Provider` | `Veriqa:TransactionEngine:Store:Provider` is used; empty there too — `PostgreSQL` | **Except on Redis.** With `Veriqa:TransactionEngine:Store:Provider` = `Redis` the sink borrows nothing: a Redis connection string is no database for the audit trail. Set `Veriqa:Logging:Store:ConnectionString` and the sink runs on its own `Provider`, or on `PostgreSQL` when that is unset; leave it unset and the trail stays in memory. Leave both unset and the trail lives in the transaction database. Give it a database of its own when: * the auditor should own the account the records are kept under, separate from the one the sign-in path writes with; * the load and backup profiles differ: transactions are gone within minutes, audit records stay for `RetentionDays`; * transactions stay in memory while the trail must survive a restart — set only `Veriqa:Logging:Store:ConnectionString`. A provider of its own on top of a borrowed connection string is refused at startup, with a message naming both `Provider` keys: set the audit connection string as well, or drop the provider. When the audit connection string differs from the transaction one, readiness gets a check of its own (`postgresql-audit` or `sqlserver-audit`). **An existing deployment.** An installation that ran without these keys keeps its `AuditRecords` in the transaction database. Setting `Veriqa:Logging:Store:ConnectionString` does not move them: the new database starts with an empty table, and the rows left behind are no longer swept by retention. Copy them over, or delete them, as part of the switch. **`Audit` mode needs a durable sink.** With `Veriqa:Logging:Mode` = `Audit` and neither connection string set, records would be kept in memory and lost on restart — so outside `Development` the container refuses to start: ``` Veriqa:Logging:Mode is 'Audit', but the audit store is in memory: set 'Veriqa:Logging:Store:ConnectionString' or 'Veriqa:TransactionEngine:Store:ConnectionString'. ``` With `Veriqa:TransactionEngine:Store:Provider` = `Redis`, with or without its connection string, the text names only `'Veriqa:Logging:Store:ConnectionString'`: that is the one key that fixes it. In `Development` the same text is logged as a `Warning`. The check runs at startup: a switch to `Audit` made later without a restart is not checked again. ## 3. Provide the token certificates Outside `Development` the server refuses to start without **both** an OpenIddict signing and an encryption certificate — there are no auto-generated development keys to fall back on. Supply each one either as a file path or inline as Base64 PFX. Any RSA-2048 (or stronger) certificate will do, and a self-signed one is enough. One way to issue both with OpenSSL: ```bash theme={null} for name in signing encryption; do openssl req -x509 -newkey rsa:2048 -nodes -days 730 -subj "/CN=veriqa-$name" \ -keyout "$name.key" -out "$name.crt" openssl pkcs12 -export -inkey "$name.key" -in "$name.crt" -out "$name.pfx" \ -passout env:PFX_PASSWORD done ``` The `PFX_PASSWORD` variable holds the password you then pass as `…CertificatePassword`. Keep the `.key` files out of the image and the repository. `openssl` creates both `.pfx` files as `-rw-------`, owned by the user that ran it — which matters as soon as you mount them, see the warning below. ```bash theme={null} -e Veriqa__OpenIddict__Server__SigningCertificateBase64="$(base64 -w0 signing.pfx)" \ -e Veriqa__OpenIddict__Server__SigningCertificatePassword="…" \ -e Veriqa__OpenIddict__Server__EncryptionCertificateBase64="$(base64 -w0 encryption.pfx)" \ -e Veriqa__OpenIddict__Server__EncryptionCertificatePassword="…" ``` The `…Path` variants (`SigningCertificatePath` / `EncryptionCertificatePath`) take a path to a PFX mounted into the container — usually the better option, since the Base64 form makes for very long environment values. **A mounted PFX has to be readable by the container user.** The image runs as UID `1654` ([step 1](#1-get-the-image)), while the files the recipe above produces are readable only by the host user that issued them. Mounted as they are, the container exits at startup with `CryptographicException: Error occurred during a cryptographic operation` — a message that names neither the path nor the access denial. Before mounting, hand the files to the image user: ```bash theme={null} sudo chown 1654 signing.pfx encryption.pfx ``` `chown` is the better of the two ways: the files stay closed to everyone but the image user. It does need root on the host — where you do not have it, `chmod 644 signing.pfx encryption.pfx` also lets the container read them, but it makes the PFX readable by **every** local user, while the password that unlocks it sits in the container environment next to it. Use `chmod` only on a host that is yours alone. The `.key` files are not mounted at all, and the Base64 form is unaffected — there the certificate travels in an environment value, not as a file. These certificates are what your issued tokens are signed and encrypted with. Keep them stable across restarts and shared across instances — rotating them invalidates every live token — and treat the passwords as secrets. ## 4. Declare your client The application that signs users in is registered as an OIDC client under `Veriqa:OpenIddict:Clients`. The image ships **no** clients, so this step is mandatory — and every client is validated at startup: a missing `ClientId`, an empty `AllowedRedirectUris` or empty `AllowedScopes` stops the container with a message naming the offending entry. Alongside an env-file this is easier to express as JSON: mount a `veriqa.json` settings file into the container at `/app/config/veriqa.json` (the variable `VERIQA_SETTINGS_FILE` moves it elsewhere; if no file exists at the path it names, the container does not start). The file may carry any Veriqa setting, not only clients; a key set through the environment or `--env-file` wins over the same key in the file: ```json veriqa.json theme={null} { "Veriqa": { "OpenIddict": { "Clients": [ { "ClientId": "my-app", "DisplayName": "My Application", "AllowedRedirectUris": [ "https://app.your-domain.com/signin-oidc" ], "AllowedScopes": [ "openid", "profile", "offline_access" ], "AllowRefreshTokens": true } ] } } } ``` ```bash theme={null} docker run -d --name veriqa -p 8080:8080 \ -v "$(pwd)/veriqa.json:/app/config/veriqa.json:ro" \ --env-file veriqa.env \ registry.gitlab.com/veriqa/veriqa ``` `redirect_uri` is matched **exactly** against `AllowedRedirectUris` — wildcards are not supported, URIs must be absolute, and `http` is accepted only for loopback addresses (`localhost`, `127.0.0.1`, `[::1]`) in development. The entry above has no `ClientSecret`, which makes `my-app` a **public client**: it is identified at `/connect/token` by PKCE (`S256`) alone, and Veriqa requires PKCE from it. Add a `ClientSecret` and the client becomes confidential — `/connect/token` then expects the secret, and PKCE is optional. The entry and the OIDC library of your application have to agree on which of the two it is: a library configured with a secret against an entry without one is refused, and so is the reverse. A secret is mandatory only for a client with `AllowClientCredentials` or `AllowIntrospection`. The scopes decide what your application may receive, not what the channel has: Telegram, for one, never hands over an email and gives the phone number only when asked. Veriqa can ask for a missing phone number or email inside the same sign-in — add the `phone` / `email` scope and raise the claim's level: [Phone number](/docs/guides/phone-number), [Progressive claim enrichment](/docs/concepts/claim-enrichment). ## 5. Point your application at it Your app is a standard OIDC client. Nothing Veriqa-specific goes into it — this is the same configuration you would write for any OpenID Connect provider. In ASP.NET Core it comes from one package: ```bash theme={null} dotnet add package Microsoft.AspNetCore.Authentication.OpenIdConnect ``` ```csharp Program.cs (your application, not Veriqa) theme={null} using Microsoft.AspNetCore.Authentication.Cookies; using Microsoft.AspNetCore.Authentication.OpenIdConnect; builder.Services .AddAuthentication(options => { options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme; options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme; }) .AddCookie() .AddOpenIdConnect(options => { options.Authority = "https://auth.your-domain.com"; options.ClientId = "my-app"; options.ResponseType = "code"; options.Scope.Add("profile"); options.CallbackPath = "/signin-oidc"; // A user who presses "Cancel sign-in" on the Veriqa page // comes back with error=access_denied. That is an answer, not a fault: with the path set // the handler redirects to that route of your application. With no path set the refusal // would go to OnRemoteFailure below, like any other failed return. options.AccessDeniedPath = "/signin-cancelled"; // Every other failed return — Veriqa answering error=server_error, say, because the channel // did not deliver the confirmation — raises OnRemoteFailure; with no handler for it the // return ends in an HTTP 500. The user is taken to a page of your application that offers // to start over. The error text is deliberately not shown to the user: it is written to // the log instead. options.Events.OnRemoteFailure = context => { context.HttpContext.RequestServices.GetRequiredService>() .LogWarning(context.Failure, "The sign-in return carried an error."); context.HandleResponse(); context.Response.Redirect("/signin-failed"); return Task.CompletedTask; }; // Veriqa issues the user's name as the short OIDC `name` claim, while User.Identity.Name // looks up ClaimTypes.Name (the long WS-Federation URI) by default — without this line the // name stays empty after a successful sign-in. options.TokenValidationParameters.NameClaimType = "name"; }); ``` The user's avatar (`picture`) is gated by the Veriqa-specific `avatar` scope: add `options.Scope.Add("avatar")` and `options.GetClaimsFromUserInfoEndpoint = true`, and list `avatar` in the client's `AllowedScopes` from [step 4](#4-declare-your-client). The avatar is never put into the `id_token` — see [OIDC + Veriqa explainer](/docs/concepts/oidc-explainer#resolved-identity-and-claims). Discovery, the authorization code flow and token issuance are ordinary OpenIddict behavior, so any conformant OIDC client library works — .NET, Node, Go, Java, Python. The block above is the registration only. To run the sign-in of [step 8](#8-verify) you also need the pipeline (`app.UseAuthentication()`, `app.UseAuthorization()`), an endpoint that issues the challenge and the two routes a failed sign-in ends on — `/signin-cancelled`, which `AccessDeniedPath` points at, and `/signin-failed`, where `OnRemoteFailure` redirects. A complete client program, all of them included, is in [step 5 of the .NET quickstart](/docs/quickstart/dotnet#5-wire-up-the-client-application). That program is written for the in-process quickstart, so two of its values are its own: `Authority` points at `https://localhost:7300`, and it asks for the `email` scope, which the client declared in [step 4](#4-declare-your-client) does not allow — either drop `options.Scope.Add("email")` or add `email` to its `AllowedScopes`. The rest applies as written. ## 6. Put it behind a reverse proxy The container speaks HTTP; TLS terminates at your proxy. For OpenIddict to build correct `https` URLs, the proxy must forward `X-Forwarded-Proto` / `X-Forwarded-For`, and the host must be told which proxies to trust — in the same `veriqa.json` from [step 4](#4-declare-your-client): ```json veriqa.json theme={null} { "ForwardedHeaders": { "KnownProxies": [ "10.0.0.5" ], "KnownIPNetworks": [ "10.0.0.0/8" ] } } ``` This is not optional in a container. By default only loopback is trusted, and a request arriving through the container network comes from the bridge gateway — so an unconfigured host silently ignores `X-Forwarded-Proto`, keeps treating the request as `http`, and even `/.well-known/openid-configuration` answers `invalid_request` ("This server only accepts HTTPS requests"). Once the proxy network is listed, discovery returns `https` URLs and sign-in works. Leave the proxy room for request headers as well. Sign-in requests carry cookies: Veriqa's own and, when your application shares the host name or a parent domain with it (all `localhost` ports count as one host during development), the client's OIDC correlation, nonce and session cookies. NGINX caps a single header at 8k by default and answers a longer one with `400 Request Header Or Cookie Too Large` — raise the limit in the `server` block: ```nginx theme={null} large_client_header_buffers 4 32k; ``` The proxy is also where per-IP rate limiting belongs. The core counts its limits per route, per user and per client, never per address, so a limiter keyed by client IP (`limit_req` in NGINX, a WAF rule, fail2ban) is part of this same step — see [Production hardening](/docs/guides/hardening#rate-limiting-and-anti-abuse). [Behind a reverse proxy](/docs/guides/reverse-proxy), a page of its own, covers which paths the proxy has to pass, the WebSocket of the sign-in page and a ready NGINX configuration. ## 7. Enable channels Channel setup — bot tokens, webhook registration, Meta Cloud API, SMTP — is identical in both delivery modes and lives in [Channel setup](/docs/guides/channels). One detail is specific to self-hosting: the channel webhooks must be reachable **from the internet** at `https://auth.your-domain.com/api/channels/…`, so the reverse proxy has to expose those paths. At least one channel is mandatory — without it there is nowhere to confirm a sign-in. The command in [step 2](#2-configure-it) enables Telegram; here is what its keys mean, so you can reach a working sign-in without leaving this page: | Key | Required | Why | | - | - | - | | `Veriqa__Channels__Telegram__Enabled` | yes | Turns the adapter on. No code registration needed — the image ships every built-in channel, configuration decides which of them are live | | `Veriqa__Channels__Telegram__BotToken` | yes | Token of a bot created through [@BotFather](https://t.me/BotFather) | | `Veriqa__Channels__Telegram__UpdateMode` | no | `Webhook` (default) or `Polling` | | `Veriqa__Channels__Telegram__WebhookBaseUrl` | in `Webhook` mode | Public HTTPS base URL of this host | | `Veriqa__Channels__Telegram__WebhookSecretToken` | in `Webhook` mode | Any random string; every inbound update is authenticated against it | | `Veriqa__Channels__Telegram__InboundVerification` | yes | How the authenticity of an incoming event of the channel is verified: `shared_secret` in `Webhook` mode, `outbound_fetch` in `Polling` mode. An enabled channel without this key keeps the host from starting; the dictionary and the values of the other channels are in [Channel setup](/docs/guides/channels) | | `Veriqa__Channels__Telegram__BotUsername` | no | Saves a Bot API round trip: when set, the `t.me` deep link behind the QR code is built directly; when empty, the bot name is resolved through the API. Does not affect update delivery | Veriqa registers the webhook in the Bot API **itself** on startup — you do not call `setWebhook` manually. Check the registration against the Bot API: ```bash theme={null} curl -s "https://api.telegram.org/botYOUR_BOT_TOKEN/getWebhookInfo" ``` Expect `url` to equal `https://auth.your-domain.com/api/channels/telegram/webhook` and `last_error_message` to be absent. The container log also carries `Telegram webhook registered: …`, but only at the `Information` level: the image in `Production` logs from `Warning`, so there the line is not written. The section is validated at startup like every other one: an enabled adapter with no `BotToken`, no `WebhookSecretToken`, or with a relative or non-HTTPS `WebhookBaseUrl` stops the container with a message naming the key. `UpdateMode=Polling` needs no public URL — handy to verify sign-in before the proxy is configured, but not for a deployed instance. Only one process may poll a bot: the Bot API hands the update queue to a single `getUpdates` caller, so a second instance on the same token receives nothing — see [Running more than one instance](#running-more-than-one-instance). ## 8. Verify **Readiness criterion: steps 1–4 green.** The process is up, dependencies answer, discovery returns `https` URLs, the channel is registered — the instance is deployed. Step 5 checks your integration (the client and the scenario), not the server itself. `curl -f http://localhost:8080/health/live` — the process is up. `curl -f http://localhost:8080/health/ready` — configured dependencies (database, Redis, RabbitMQ) answer. This is the probe to wire into your orchestrator. `curl https://auth.your-domain.com/.well-known/openid-configuration` returns the OIDC metadata with `https` URLs. If they come out as `http`, revisit [step 6](#6-put-it-behind-a-reverse-proxy). For `Webhook` mode: `getWebhookInfo` of the bot returns your `/api/channels/telegram/webhook` as `url` with no `last_error_message` — see [step 7](#7-enable-channels). With an empty `url` there is nothing to confirm sign-ins with. In `Polling` mode there is no such call: read `/health/channels` instead, and mind what it does not answer (below). Trigger sign-in from your application and confirm it in a trusted channel on your phone. With Email — and with any channel when `Veriqa:LoginConfirmation:Mode` is `OnWebPage` — the sign-in page then asks once more: confirm there too. **What `/health/channels` answers — and what it does not.** The endpoint reports every enabled channel separately, and the per-channel value is what you read: `healthy`, `unhealthy` or `timeout`. * **The HTTP status is always `200`.** A channel outage is reported as `Degraded`, never as `Unhealthy`, by design: an unreachable messenger must not take a working replica out of rotation. `curl -f` therefore succeeds even with every channel down — alert on the body, not on the status code. For the same reason channels are absent from `/health/ready`. * **The report may be stale.** It is a snapshot refreshed channel by channel — each channel at most once in its own interval, by default once every 15 seconds (`Veriqa:Channels:{Channel}:HealthProbeInterval`) — and only a read moves it: the read that finds a channel's snapshot too old still answers from that old one and refreshes it in the background. The first read after a channel goes down can still say `healthy`, the next one tells the truth — so poll the endpoint on an interval if you alert on it. * **It does not know who owns the update queue.** The probe is an authenticated call to the platform, so a wrong or revoked token does come out as `unhealthy` — but a token that is valid and already taken by another poller answers perfectly. See [Running more than one instance](#running-more-than-one-instance). ## 9. Common first-run failures Almost everything that breaks on a first run breaks **at startup** — the container does not stay alive half-broken, it exits with a message naming the key. So the first move on any failure is `docker logs veriqa`. | Symptom | Cause | Where to go | | - | - | - | | Container exits: `Set Veriqa:OpenIddict:Server:SigningCertificateBase64 … The parameters are required in a non-Development environment.` — or the same about `EncryptionCertificateBase64` | Outside `Development` **both** certificates are required. The message names the one that was missing: with neither set, signing fails first; with only signing set, encryption does | [Step 3](#3-provide-the-token-certificates) | | Container exits at startup: `CryptographicException: Error occurred during a cryptographic operation`, with `SigningCertificatePath` / `EncryptionCertificatePath` set | The mounted PFX is not readable by the container user (UID `1654`): `openssl` leaves the file readable only by its owner. The message names neither the path nor the access denial, so it reads like a password or format problem | [Step 3](#3-provide-the-token-certificates) — `sudo chown 1654` on the files before mounting them; `chmod 644` only where root is unavailable, and it makes the PFX readable by every local user | | Container exits: `Clients[0] (my-app): AllowedRedirectUris cannot be empty.` (or `ClientId cannot be empty.`, `AllowedScopes cannot be empty.`, `invalid redirect URI '…'`) | An entry of `Veriqa:OpenIddict:Clients` is incomplete | [Step 4](#4-declare-your-client) | | Container exits: `BotToken is required when the Telegram adapter is enabled.` or `WebhookBaseUrl must use HTTPS (a Telegram Bot API requirement).` | The channel is enabled but not fully configured | [Step 7](#7-enable-channels) | | Container exits: `Unsupported value 'Veriqa:OpenIddict:Database:Provider' = 'Postgres'. Allowed: 'InMemory', 'PostgreSQL', 'SqlServer'.` | A typo in the provider value. The resolver is fail-fast: there is no silent fallback to `InMemory` | [Step 2](#2-configure-it) | | Container exits: `Veriqa:Logging:Mode is 'Audit', but the audit store is in memory: set 'Veriqa:Logging:Store:ConnectionString' or 'Veriqa:TransactionEngine:Store:ConnectionString'.` | `Audit` mode is on, but neither the audit nor the transaction store has a connection string — outside `Development` the records would have nowhere durable to go. With `Veriqa:TransactionEngine:Store:Provider` = `Redis` the text names only the audit key: a Redis store lends the sink nothing | [Audit sink](#audit-sink) | | Container exits: `With 'Veriqa:OpenIddict:Database:Provider' = 'PostgreSQL' you must set 'Veriqa:OpenIddict:Database:ConnectionString'.` | The relational provider is set, the connection string is not. The transaction store follows a different rule: there the **absence** of a connection string legitimately selects the in-memory store | [Step 2](#2-configure-it) | | Container is alive, but `/.well-known/openid-configuration` answers `invalid_request` ("This server only accepts HTTPS requests") | The proxy is not listed as trusted, `X-Forwarded-Proto` is ignored | [Step 6](#6-put-it-behind-a-reverse-proxy) | | Container is `healthy` and `/health/ready` answers 200, but `/connect/authorize` answers `503` with `"title": "channel_display_failed"` | The channel's token is wrong or revoked, and the channel runs in `Polling` mode: the key is set, so the channel passes startup validation, and the platform rejects the token only when the sign-in page is prepared. In `Webhook` mode the same token already fails the webhook registration at startup, and the container exits | `/health/channels` — the channel is reported as `unhealthy` (channels are deliberately kept out of readiness); the reason is in `docker logs veriqa` | | Container exits at startup with `bad webhook: …` (for example `bad webhook: Failed to resolve host`), the token is correct | Telegram could not reach `WebhookBaseUrl`: the host name has not propagated to DNS yet, the certificate is not accepted, or the address is unreachable from the internet | Wait until the name resolves through an external resolver (`dig @8.8.8.8 auth.your-domain.com`) and start again, or begin with `UpdateMode=Polling` — [step 7](#7-enable-channels) | | The sign-in page opens, but nothing arrives in the messenger (or the button does not respond) | The `/api/channels/telegram/webhook` path is not exposed by the proxy, or `WebhookBaseUrl` does not point at the public address | [Step 7](#7-enable-channels) | | The same, in `Polling` mode — and `/health/channels` still reports the channel as `healthy` | Another process is already polling this bot, most often a staging installation left running on the production token. The Bot API gives the update queue to one `getUpdates` caller, and the loser receives nothing; its token stays valid, so the health probe sees no problem | `docker logs veriqa` — a repeating error of the polling loop carrying `Conflict: terminated by other getUpdates request` (the `409` of that refusal is not part of the logged text). One poller per bot: [Running more than one instance](#running-more-than-one-instance) | ## Running more than one instance A single container needs nothing extra. Scaling out requires shared state: | Concern | Setting | Why | | - | - | - | | SignalR backplane | `Veriqa:SignalR:RedisConnectionString` | Live sign-in status must reach the browser whichever instance it is connected to | | DataProtection keys | `Veriqa:DataProtection:RedisConnectionString` or `:KeysDirectory` | Without shared keys, instances cannot read each other's cookies and tokens | | Channel stores | `Veriqa:ChannelStores:Redis:ConnectionString` | Without it each instance keeps them in process: an Email magic link, a start code, a link handle or a shared phone number that lands on another instance is not found — see [Channel stores: multiple replicas](/docs/guides/storage#channel-stores-multiple-replicas) | | Transaction store | `Veriqa:TransactionEngine:Store` on a relational database or `Redis` | An in-memory store is per instance: a sign-in started on one instance is unknown to the others | Leave the first two unset and each instance keeps its own in-memory keys — fine for one container, broken for several. A shared volume for `KeysDirectory` works as an alternative to Redis. One more thing does not scale by itself: **update delivery in `Polling` mode**. The Bot API hands the update queue to a single `getUpdates` caller, so a second instance on the same bot token receives nothing at all — while reporting itself `healthy`, because its token is valid and the platform answers it. Only the log gives it away: the polling loop repeats the `409` refusal of the Bot API, written out as `Conflict: terminated by other getUpdates request` — the number itself is not in the line. Scale out on `Webhook` mode, where every instance is fed by the proxy, or give each installation its own bot — that last one applies to a staging installation standing next to production just as much as to a second replica. ## Next steps Telegram, WhatsApp, other channels and Email — end to end. Every section, key and default in one place. What to check before shipping. Transaction store, retention and migrations. The same server without Docker — systemd or a Windows service, from an archive. The paths to pass, the WebSocket and an NGINX configuration. Ask for what the channel did not give, inside the same sign-in. # Samples Source: https://veriqa.app/docs/samples The runnable samples and copy-into-your-code clients in the repository: what each one shows, where it lives and how to start it. The repository ([veriqa.app/source](https://veriqa.app/source)) carries a gallery of samples under `samples/`. Everything there is **MIT-licensed** and written to be lifted into your own project. **Veriqa lives on the issuer side.** An application that only signs users in stays a plain OpenID Connect relying party — no Veriqa package, no SDK. Veriqa packages appear only where the application *is* the issuer (embedded) or calls the confirmation API server-to-server. Samples come in two forms: * **Runnable** — projects with their own `README.md`, ports and configuration. Start a .NET sample with `dotnet run --project `; the samples outside .NET that run next to Veriqa are started with `docker compose up` in their folder. * **Code to copy** — one file per stack with the complete client wiring. There is no project file around it: the surrounding application is yours. ## Sign-in and confirmation The issuer with Veriqa embedded, a stock OIDC client, the same sign-in under .NET Aspire and the client code for Java, Node and Python. A backend asks a person to confirm an action: a plain confirmation, step-up before a destructive action and approval of an AI agent's tool call. ## Showcase samples Showcases imitate a whole product, so the flows can be seen end to end the way a user encounters them. Sign-in by QR code from the sofa, a date of birth and a favorite genre collected inside the sign-in, and step-up when buying a title; written for TV browsers. A public support page, a sign-in that asks for no contact, a phone or email confirmed later, and step-up before a ticket is closed or deleted. A Claude Code plugin: selected agent actions wait until a person approves them in the messenger. ## Channel and deployment samples | Path | What it shows | Form | | - | - | - | | `dotnet/custom-channel` | A third-party channel plugged in through the public SPI — see [Custom channel adapter](/docs/guides/custom-channel-adapter) | runnable · `https://localhost:7320` | | `python/custom-channel-telegram` | Telegram through an external adapter in Python, plugged in by configuration — see [An HTTP channel adapter in any language](/docs/guides/custom-channel-adapter#an-http-channel-adapter-in-any-language) | runnable · `docker compose` | | `node/custom-channel-whatsapp-twilio` | WhatsApp through Twilio, an external adapter in Node — see [An HTTP channel adapter in any language](/docs/guides/custom-channel-adapter#an-http-channel-adapter-in-any-language) | runnable · `docker compose` | | `node/custom-channel-whatsapp-baileys` | WhatsApp through Baileys, an external adapter in Node — a working adapter for debugging WhatsApp flows, for small teams and for personal use; **an unofficial WhatsApp Web client, the number may be banned**; see [An HTTP channel adapter in any language](/docs/guides/custom-channel-adapter#an-http-channel-adapter-in-any-language) | runnable · `docker compose` | | `docker-compose/` | Self-hosted auth server behind Nginx with PostgreSQL, Redis and RabbitMQ — demonstration only | deployment | The custom-channel host and the Aspire sign-in sample share port 7320 — run them one at a time. ## Running the .NET samples * .NET SDK 10.0+ and a trusted local HTTPS certificate (`dotnet dev-certs https --trust`). * **Enable at least one channel and give it credentials.** Every sample ships with all channels `"Enabled": false`; with none enabled the sign-in page returns `no_channels_available`. A bot token is the quickest route — see [Channel setup](/docs/guides/channels) and the sample's own README. * In pairs such as `login` + `login-client`, start the issuer first: the client fails discovery otherwise. # Channel buttons on your own page Source: https://veriqa.app/docs/cloud/channel-buttons When your application renders the sign-in buttons: the bootstrap request, the channels_enabled and feature_flags fields, caching, and what happens when the cloud is unreachable. You need this path only if you render the "Sign in with Telegram / WhatsApp / …" buttons **yourself** instead of sending the user to the Veriqa sign-in page. No buttons of your own means no need for this page — [the ordinary connection](/docs/cloud/connect) is enough. ## Why `bootstrap` exists The set of enabled channels is decided by the project owner in the Console and can change at any time. A list hardcoded in your code turns into a dead button: the channel is switched off, the user taps it, and nothing works until your next deploy. `bootstrap` is a single GET at application start that fetches the **current** project configuration instead of keeping it in your own config. It is a Veriqa extension, not a standard OIDC endpoint. ```http theme={null} GET /p/{project}/v1/bootstrap?client_id={client_id} X-Veriqa-Cloud-Key: {cloud_key} // optional Accept: application/json ``` Without the cloud key the response carries public fields only. The response is the **effective** configuration for that specific `client_id` — the project's values narrowed by the application — not a dump of project settings. ## What comes back ```json theme={null} { "issuer": "https:///p/{project}", "authorization_endpoint": "…/connect/authorize", "token_endpoint": "…/connect/token", "userinfo_endpoint": "…/connect/userinfo", "jwks_uri": "…/.well-known/jwks.json", "channels_enabled": ["telegram", "whatsapp", "max", "email"], "feature_flags": { "step_up_enabled": true, "channel_linking_enabled": true }, "ttl_seconds": 300 } ``` Two of these fields do the work; the rest are conveniences: * **`channels_enabled`** — the reason the mechanism exists: exactly the set of buttons to show right now; * **`feature_flags`** — what tells you whether to surface step-up, channel linking and operation approval in your own interface. The endpoint addresses duplicate discovery and save one request at startup. `ttl_seconds` is how long the response may be cached, 300 seconds by default; after that, fetch it again. The stable fields of the contract are `channels_enabled` and `feature_flags`. Build your logic on them, and do not tie code you would hate to rewrite to the other fields. Numeric limits, if the response carries them, are **informational**: not a commitment, and not a replacement for the protection running on the cloud side. There is no branding in the response. The project name, logo, color and theme are applied by the **server** when it renders the sign-in page — the integrator does not need them, and they are not part of the contract. ## Behavior on failure | Situation | What happens | | - | - | | cloud unreachable, no cache | immediate failure — the application should not run on guesses | | cloud unreachable, cache valid | with cached fallback enabled: the old configuration, a warning and a background refresh; otherwise failure | | `404` for the `client_id` | failure: the application is not in this project | | `401` | failure: the cloud key is wrong or missing | | the response does not match the contract | failure | ## What bootstrap does not guarantee The `bootstrap` response is **not a promise** of what the user will see. The authoritative resolution happens on the server at `/connect/authorize`: a `ui_config` or `acr_values` you pass, along with per-user overrides, can produce a different result. Treat bootstrap as the way to learn what is enabled in the project, not as a preview of the sign-in window. # Connecting to the cloud Source: https://veriqa.app/docs/cloud/connect The main path into Veriqa Cloud: five project addresses, an ordinary OIDC client, and no Veriqa libraries. The main path is an ordinary redirect: the user goes to the Veriqa sign-in page and returns to your application. From your code's point of view this is a **standard OIDC client** — the same one you would set up for any provider. ## What you need * **the project address**: `https:///p/{project}`; * the application's **`client_id`** and, if it is confidential, its **`client_secret`**; * a **`redirect_uri`** registered in the Console. Veriqa libraries are **not required**. Neither is any preliminary request for configuration: everything the client needs is in the discovery document. `bootstrap` plays no part on this path. It is only needed when your own page renders the channel buttons — see [channel buttons](/docs/cloud/channel-buttons). ## The five addresses that are enough | Method | Path | | - | - | | GET | `/p/{project}/.well-known/openid-configuration` | | GET | `/p/{project}/.well-known/jwks.json` | | GET | `/p/{project}/connect/authorize` | | POST | `/p/{project}/connect/token` | | GET | `/p/{project}/connect/userinfo` | Point your library at the **issuer** — it will take the rest from discovery. Spell the addresses out by hand only where the client cannot do discovery. ## Authenticating requests | What you call | How you authenticate | | - | - | | `token`, `introspection`, `revocation` | `client_id` + `client_secret` (Basic or `client_secret_post`); a public client uses PKCE without a secret | | `status`, `health` | no authentication | The cloud endpoints beyond OIDC use the `X-Veriqa-Cloud-Key` header, described on the channel buttons page. ## Parameters `/connect/authorize` understands Besides the standard `scope`, `state`, `nonce` and PKCE: * **`acr_values=channel:`** — narrow the channels down to the ones listed (separate multiple values with a space); * **`ui_locales`** — the language of the sign-in window; * **`ui_config`** — the code of a settings bundle created by the project owner; see the customization section. ## Checking things on the spot `GET /p/{project}/v1/status` returns the project and channel status, and `GET /v1/health` the health of the cloud instance. Both are public, which makes them handy for monitoring and for quickly answering "is it me or the cloud". Addresses like `https://cloud.veriqa.io/...` in examples are illustrative. Your cloud's actual host is shown in the Console. # Veriqa Cloud Source: https://veriqa.app/docs/cloud/overview What the Veriqa cloud is, how it differs from running your own deployment, and which settings belong to the project owner rather than to your code. Veriqa Cloud is the same messenger-based authentication, run and maintained by someone other than you. Instead of your own server you get a **project** in the cloud: its own issuer, its own applications, and its own set of enabled channels. **Veriqa Cloud is currently unavailable.** This page describes the contract of its API. ## Concepts | Term | What it is | | - | - | | **Project** | a cloud tenant; one project is one OIDC issuer | | **Application** | an OIDC client inside the project: a `client_id` and, if confidential, a `client_secret` | | **Cloud project key** | a project-level private key for the cloud endpoints; rotated without changing `client_id` or the secret | | **Channel** | how the confirmation is delivered: `telegram`, `whatsapp`, `max`, `email` | | **`ui_config`** | a named "branding plus flow behavior" bundle owned by the project and assigned to applications | `{project}` in the addresses is either a slug or a UUID — both are accepted. ## Who configures what The split is simple, and it is the main reason the cloud is less work than your own server: * **the project owner**, in the Console: which channels are enabled, the sign-in window branding, the `ui_config` sets, the confirmation and audit policies; * **you, the integrator**, in your code: `issuer`, `client_id`, the secret, and handling the return. The channel set changes in the Console **without touching or redeploying your application**. That is exactly why the list of enabled channels should not be hardcoded — see [channel buttons on your own page](/docs/cloud/channel-buttons). There is no public API for managing projects, applications or keys — all of that happens in the Console. ## Two ways to connect The user goes to the Veriqa sign-in page and comes back. All you need is an ordinary OIDC client: `issuer` plus `client_id`. No Veriqa libraries involved. Your page renders the "Sign in with Telegram / WhatsApp / …" buttons. That additionally needs a `bootstrap` call to learn which channels are enabled right now. Do not conflate them: they are different amounts of work. If you do not need your own buttons, the whole `bootstrap` section is safe to skip. ## How the cloud differs from self-hosted Beyond who runs it, the API surface differs too: | Endpoint | Standard | Self-hosted | | - | - | - | | `/p/{project}/connect/revocation` | RFC 7009 | **present** at `/connect/revoke`, enabled by default | | `/p/{project}/connect/introspection` | RFC 7662 | absent | | `/p/{project}/connect/endsession` | OIDC Session Management | absent | So there are two real differences — introspection and sign-out. When planning a move in either direction, do not assume the two are equivalent here. # Sign-in window settings (ui_config) Source: https://veriqa.app/docs/cloud/ui-config A named "branding plus flow behavior" bundle: who creates it, how an application selects it, and why the server decides the outcome rather than your request. `ui_config` is a **named bundle of sign-in window settings** owned by the project. It holds the branding and the flow behavior; an application does not describe those settings itself — it names the code of the bundle it wants. ## Who does what * **the project owner** creates the bundles and gives them codes, dynamically, in the Console; * **the application** is assigned a default bundle and a list of allowed codes; a code outside that list cannot be passed. The application selects a bundle with an authorize parameter: ``` &ui_config=checkout-dark ``` The parameter is optional. If it is absent, the application's default bundle applies. ## What a bundle controls * which **channels** are shown and how — all at once or one (`ChannelDisplayMode`); * how several channels **share the window** — behind tabs or side by side (`channel_layout`); * on which devices the **channel buttons** are shown (`channel_button`); * whether the window offers the **"Cancel sign-in" button** (`cancel`); * the **design template** of the window; * the **post-confirmation behavior**: what happens to the window and whether the flow continues automatically; * the **confirmation text**; * the **wording of individual page elements** — the `texts` member. `texts` rewords built-in texts of the sign-in window and of the other pages Veriqa renders, for the applications that select this bundle. An entry is keyed by the English text of the element; its value is a replacement key that goes through the locale files, or ready-made texts per language that bypass them: ```json theme={null} "texts": { "Get a link": "Get the form link", "Sign in via Email": { "en": "Confirm via Email", "ru": "Подтвердить через Email" } } ``` An entry of the bundle outranks the application's and the project's entry for the same text; entries for other texts set there stay in force. A text the bundle sets with its own field (the title, the instruction) is not reworded by `texts`. Which texts are open, and what happens to an entry that does not fit, is in [Rewording a page element for one application](/docs/guides/customization#rewording-a-page-element-for-one-application). `channel_layout`, `channel_button` and `cancel` are the bundle's side of three settings of the sign-in window: ```json theme={null} "channel_layout": "Grid", "channel_button": { "show_channel_button": "MobileOnly" }, "cancel": { "enabled": false } ``` * `channel_layout` — `Tabs` (the default) or `Grid`: every channel on screen at once, each with its own QR code. See [Channel layout](/docs/guides/customization#channel-layout). * `channel_button.show_channel_button` — `Always` (the default), `MobileOnly` or `Never`. A value that, together with the QR code visibility, would leave a device with neither the QR code nor the button is not applied: the buttons are then shown on every device. See [Channel button visibility](/docs/guides/customization#channel-button-visibility). * `cancel.enabled` — `true` or `false`. The button is on unless some level switches it off; pressing it returns the user to the application with `error=access_denied`. See [Cancel button](/docs/guides/customization#cancel-button). A member the bundle leaves out sets nothing, and the value comes from the application, the project or the core. What `ui_config` does **not** control — a common mismatch of expectations: the **sign-in confirmation mode** and the **audit mode**. Those are the deployment owner's policies and cannot be overridden through a window settings bundle. ## Resolution order The final value of any setting is assembled along a chain, from the most specific level to the most general: ``` user → request → ui_config → application → project → core ``` The first level where the value is both set **and permitted** wins. A higher level can only **narrow** what a lower one allowed: protective settings define a ceiling, not a suggestion. This is the answer to "why am I not seeing what I configured": the value is most likely set at a more specific level, or clipped by a ceiling at a more general one. **The authoritative resolution happens on the server at `/connect/authorize`**, not in whatever [bootstrap](/docs/cloud/channel-buttons) returned. Bootstrap shows what is enabled in the project; the window's final appearance is decided at request time. ## What is missing There is no machine-readable way to obtain the list of allowed `ui_config` codes: the bootstrap response does not carry it, even though `/connect/authorize` validates the code against an allowlist. Ask the project owner or look in the Console. # Architecture Source: https://veriqa.app/docs/concepts/architecture The building blocks behind the Veriqa connector. Veriqa is the bridge between three parties: your application, OpenIddict, and the user's trusted mobile channel. It initiates a sign-in or confirmation transaction, links it to the chosen channel, waits for the user to confirm, then completes authentication in OpenIddict. ```mermaid theme={null} flowchart LR App["Client app
(web / desktop)"] subgraph Veriqa["Veriqa (in your host)"] direction TB TE["Transaction
engine"] CA["Channel
adapters"] OI["OpenIddict
integration"] end Channel["Trusted app
Telegram / WhatsApp /
other channels / Email"] OIDC["OpenIddict"] App <--> TE TE --- CA TE --- OI CA <--> Channel OI --> OIDC OIDC -.->|auth result| App ``` ## Building blocks Every sign-in, registration or confirmation is a transaction with its own context, correlation id, state, lifetime, chosen channel, external identity, local account link and audit log. Each messenger or mobile channel is a separate adapter / connector. Out of the box: Telegram, WhatsApp, other channels and Email — designed to be extensible. Completes primary sign-in, second factor, step-up confirmation and channel linking — without replacing OpenIddict. The application finishes the flow via callback, SignalR live updates or polling once the transaction is confirmed. Pairs the user's messenger identity with the local account, optionally linking a notification channel in the same step. Each transaction carries an audit log of state changes and confirmations. ## QR orchestration Veriqa generates a short-lived entry point per transaction. The QR code does not carry the authentication logic — it simply opens the right bridge-flow for the chosen channel and ties the user to the current transaction. From starting the transaction to issuing OpenIddict tokens — step by step. # How the auth flow works Source: https://veriqa.app/docs/concepts/auth-flow The full OIDC + Veriqa flow: from starting the transaction to issuing OpenIddict tokens. Veriqa runs **inside your ASP.NET Core host** and is the OpenIddict auth server. The sign-in flow is always the same: start a transaction, confirm it in a trusted channel on the phone, complete authentication in OpenIddict. To your application it is a plain OIDC Authorization Code Flow — Veriqa simply replaces the sign-in form with a messenger confirmation. ```mermaid theme={null} sequenceDiagram autonumber participant App as Client app (desktop) participant V as Veriqa (in your host) participant OI as OpenIddict participant Ch as Trusted channel (phone) App->>V: Redirect to /connect/authorize V-->>App: Sign-in page + QR / channel buttons Note over V,Ch: A transaction opens, bound to the chosen channel Ch->>V: User opens channel, confirms sign-in V->>V: Update transaction state App->>V: Live status (SignalR) or polling /api/transaction/{id}/status V->>OI: Complete authentication (ClaimsPrincipal) OI-->>App: code → /connect/token → tokens ``` ## Step by step The app redirects the user to `/connect/authorize`. Veriqa opens a transaction with the client context, a correlation id, a lifetime and the chosen channel, and renders the sign-in page with a QR code and channel buttons. The user opens the trusted channel on their phone (Telegram / WhatsApp / other channels / Email) and confirms the sign-in. The channel adapter returns the resolved identity to the transaction. The desktop learns of completion via a live update (SignalR) or by polling the transaction status, after which Veriqa builds a `ClaimsPrincipal` and hands it to OpenIddict. OpenIddict issues an authorization code; the app exchanges it for tokens at `/connect/token`. From there it is standard OIDC. ## Starting the sign-in again A user may start a sign-in to the same application again — in a new tab or with a second scan — and reach the channel with the same account. That closes the sign-ins of that account to that application that were still waiting for an answer, including a sign-in waiting for missing claims. Only the one that reached the channel last stays: * the chat gets no message about the closed sign-ins — no receipt and no "time expired" notice. If the user presses a button on a question left in the chat, they get the usual expired receipt; * their open pages show the expired screen and stay where they are: your application receives no `error` on `redirect_uri` for a sign-in the user has left; * in events, the audit trail and the transaction status such a sign-in is a failed transaction with the reason `superseded_by_new_sign_in`; in [metrics](/docs/guides/observability#metrics) its outcome is `superseded`. Sign-ins are matched by tenant, application, channel type and the user's identifier in that channel, at the moment the newer one is first tied to the channel account — the start in a messenger, the link in an email, an incoming email. A sign-in that has already finished and a [server-to-server confirmation](/docs/reference/confirmation-api) are never closed this way. ## Veriqa vs OIDC terms | Veriqa term | OIDC term | | - | - | | Transaction start | `authorize` request | | Channel confirmation | user authentication | | Completion (SignalR / polling) | `callback` → token issuance | | Resolved identity | ID token claims | ## Further reading Exactly where Veriqa slots into the standard OIDC flow — and what it doesn't do. Wire the flow into your application. # Progressive claim enrichment Source: https://veriqa.app/docs/concepts/claim-enrichment The application fills in the user's profile as it goes, receiving verified data at the moment of a confirmation instead of demanding a form up front. A conventional registration asks for everything at once — name, email, phone — before the person has decided whether they want the service at all. Half the fields get filled with noise, and none of them were ever verified. **Progressive claim enrichment** inverts the order: the user signs in with a single action, and the missing data arrives later — at the moment it is actually needed, and **already verified** wherever the channel can vouch for it. ## Two ways to get a missing claim **Inside the sign-in.** The [`Veriqa:ScopesClaims`](/docs/reference/configuration#veriqascopesclaims) catalog gives each claim a level: `Required` — the sign-in does not complete without it; `Optional` — the user is asked and may skip it; `IfAvailable` — issued only when the channel or the collected claims store already has it. An application raises a level for itself with `ClaimLevels` of its client entry, and a request raises it with the [`claims` parameter](/docs/reference/oidc-capabilities). What the channel did not give, Veriqa collects before it issues the tokens, in the same transaction: the phone number by a request in the channel ([Phone number](/docs/guides/phone-number)), the email address by an email that confirms it or by an email the user sends (`ClaimCompletionEmailMethods` of the [client entry](/docs/reference/configuration#clients)), and a field of the catalog that has `Completion` on a form of its own — a text, a date or a choice from a list (`Format: Choice`). The [Nova Stream showcase sample](/docs/samples/showcase/streaming-tv) collects a date of birth and a favorite genre this way. **How the email is collected.** The user sees at most three options, in the order of the list: * **Confirm via email** — the user types the address and presses one button. What arrives is decided by the list, not by the user: a code to enter on the page (`Code`), a link to a confirmation page (`MagicLink`), or one email with both (`claim-code-link-mail`) when the list names both — whichever the user does first confirms the address. `email_verified` is `true`; * **an email the user sends** (`Push`) — on the device that confirmed the sign-in; `email_verified` is `true`; * **without confirmation** (`Unverified`) — the address is only typed; `email_verified` is `false`. A value typed into the other fields of the form survives every step of the email flow — requesting the email, entering the code, switching the method. "Continue" is always there: pressed before the address is confirmed, it saves the fields and the page says "Confirm the address to continue." Pressed with a code or an address typed into the email block, it does what that input is for instead — checks the code, sends the email, or saves the address without confirmation — and leaves the other fields as typed. Another email cannot be requested sooner than `Veriqa:Channels:Email:MailResendInterval` after the previous one — see [Sending an email again](/docs/guides/channels#sending-an-email-again). What the email block shows as the user proceeds: * **After the email is sent**, the code field and **Send again**. "Send again" sends nothing by itself: it brings back the address field, filled with the address of the last email, so the user either sends the same email once more or corrects the address first. * **A code whose attempts are spent** turns the block back to that address field while emails are left within the limits; when none are left, the block says "The code has no attempts left." and asks for nothing more. When the per-address limit is reached, the block says "Too many emails have been sent to this address. Try again later or enter another address." * **An address confirmed by the link in the email** — opened on a phone, say — reaches, on its own, the form on the device the sign-in started on: the form continues as if "I have confirmed the address" were pressed, keeping what the user typed into the other fields. A page that fell back to polling keeps the button for that. * **A recorded address** stays in the block while other fields of the form are still open: "The address … is confirmed", or "The address … is saved" for an address taken without confirmation. The page the link in the email opens offers **Cancel** beside "Confirm the address", and **Close** once the address is confirmed; both only close the tab. **By a new authorize request.** When the need comes later, the application starts one more sign-in with the scopes it now needs: 1. **First sign-in** — the minimum: `sub` plus whatever the channel gives under `scope=profile`. The account exists and the service is usable. 2. **A contact is needed** — before the action that needs it, the application starts a new authorize request with `scope=email` or `phone`. The user confirms in the messenger and the claim arrives in the `id_token`. 3. **A notification channel is needed** — the same move with `scope=channel`: see [channel linking](/docs/scenarios/channel-linking). The Helio [showcase sample](/docs/samples#showcase-samples) confirms a phone number and an email this way, each by a request of its own. Either way there is no separate enrichment transaction and no separate API: every step is an ordinary sign-in with the scopes and levels you need. ## Why the data is verified The difference from a form is **who the source of the value is**. An address typed into a form is a claim the user makes; an address that arrives as a claim from the Email channel follows from the person having confirmed a sign-in from that very mailbox. There is nothing left to verify with a separate email. The same holds for what Veriqa collects: a number shared in the channel and an address confirmed by a code come with `phone_number_verified` / `email_verified`, while a form field is what the user typed. Which claims the **channel** gives depends on the channel, not on your request: Telegram does not provide an email and never will, however many scopes you ask for. What the channel does not give, Veriqa can collect inside the sign-in — but only `phone_number`, `email` and the catalog claims with `Completion`; any other claim can only be `IfAvailable`. The catalog lists what each channel gives: [Supported channels](/docs/guides/supported-channels). ## The right moment to enrich The easiest place to fill in a profile is where the user is confirming something anyway: before a payment, a change of payment details, granting access. The confirmation and the data collection merge into one action instead of two — see [step-up](/docs/scenarios/step-up). ## Remembering what was collected By default Veriqa keeps nothing after the sign-in: every sign-in starts from what the channel gives, and what Veriqa collected lives only as long as the transaction. **An `Optional` claim is then asked for at every sign-in** — the user who skipped it yesterday sees the question again today. The [collected claims store](/docs/guides/storage#collected-claims-store) is an option that changes this. With it Veriqa remembers by `sub` what it collected itself, and which `Optional` claims the user declined, and asks only for what is still missing. A declined `Optional` claim is not asked for again until its level is raised to `Required`. A stored value is checked against the current rules on every sign-in: a value that no longer passes a tightened field rule, or an address obtained in a way the current application does not allow, is asked for again. What to know before turning it on: * **Records follow the `sub`, and one person has a different `sub` in every channel.** A user who signs in through Telegram and then through WhatsApp is asked again in the second channel: Veriqa does not link accounts across channels. * **It is a record of what was collected, not a copy of your profile.** Veriqa does not edit it and does not synchronize it with your application. A token with a stored value in it also has `updated_at` — the latest time Veriqa collected a value the token carries, in seconds since the epoch. If the user has changed the data on your side since then, keep yours: do not overwrite your profile from the token at every sign-in, compare `updated_at` with the time of your own change. * **It is shared by all applications of a tenant**, and so is a deletion. Deleting the records of a user — at your request or after a period without sign-ins — is described in [Storage](/docs/guides/storage#deleting-by-sub). ## What stays on your side * **deciding what data you need and when** — the levels in the catalog and in the request; Veriqa has no profile model beyond them; * **your profile** — a claim that arrives in the token is your data from then on. The collected claims store, when you turn it on, keeps only what Veriqa collected, to avoid asking again — it is not a user directory; * **the re-ask policy** — how long a received value stays valid in your application and when to ask again is yours to define; * **erasure** — when a user deletes their account, delete their records from the collected claims store as well; Veriqa does not know about the deletion otherwise. # How Veriqa differs Source: https://veriqa.app/docs/concepts/comparison A comparison with passkeys, built-in messenger sign-in widgets, SMS codes and device flow: where Veriqa is stronger, and where its limits are. Veriqa complements existing solutions rather than replacing them. Here is where it is stronger — and where its limits are. Sign-in times and setup effort in figures, with the source of each one, are in [Sign-in methods compared](/docs/concepts/sign-in-comparison). One thing holds across the whole comparison: Veriqa's primary scenario is **cross-device**. The application is open on a desktop, a TV or a kiosk, while the confirmation happens on a phone, in a messenger the user already opens every day. Same-device sign-in is supported, but it is the fallback here, not the main path. ## At a glance | Alternative | Where Veriqa is stronger | When to choose the alternative | | - | - | - | | [Passkeys](#passkeys) | Nothing to set up in advance; works with Bluetooth off, in a VM or over remote desktop; also covers step-up, channel linking and action confirmations | You need phishing-resistant security | | [Messenger sign-in widgets](#built-in-messenger-sign-in-widgets) | One integration for all channels and email; full OpenID Connect instead of a JS widget; the messenger need not be on the same device | — | | [SMS codes](#sms-codes) | Faster, not billed per message, no phone number handed over, no code to retype | — | | [Device flow](#device-flow-rfc-8628) | One tap in a messenger instead of a full sign-in on the second device | The second device has no messenger on it, and you need a strictly standard mechanism | ## Passkeys The closest in user experience: the same "QR on screen, confirmation from the phone" flow. **Passkeys are phishing-resistant by design — a higher security bar.** Where that bar is what you need, passkeys are the right choice. Veriqa offers comparable convenience for simpler scenarios. Where Veriqa wins: * **nothing has to be set up in advance** — no key is provisioned for the user; they confirm in a messenger they already have; * **it works where cross-device passkeys break**: Bluetooth off, a virtual machine, a remote desktop, mismatched ecosystems between the computer and the phone; * **it is not only sign-in**: second factor, channel linking, sensitive-action confirmation and an audit trail come from the same mechanism. ## Built-in messenger sign-in widgets Telegram and others have sign-in buttons of their own, and they are free. The difference is not about price: * **one integration layer for all channels and email** instead of a separate widget per messenger; * **full OpenID Connect instead of a JS widget**: tokens, sessions, account linking — your auth system never has to learn what a messenger is; * **the messenger need not be installed on the surface people sign in from**. A widget assumes the app and the messenger share a device; with Veriqa they are deliberately different devices; * beyond sign-in — second factor, channel linking and an audit trail. ## SMS codes * confirmation in a messenger is **faster** and lives in an app the user opens every day; * messages are **not billed per message** the way SMS is — at noticeable volumes that adds up to direct savings; * the identity is confirmed **without handing over a phone number** — less data that can leak; * no retyping a code between screens: the confirmation is one tap in the messenger. ## Device flow (RFC 8628) The standard answer to "how do we sign in on a device without a keyboard": the device shows a code, the user types it on another device and confirms. The difference is **what the user does on that second device**. In device flow they open a browser, go to the provider's page, enter the code and authenticate — an ordinary sign-in with all of its steps. With Veriqa they tap one message in a messenger where they are already signed in. Device flow remains the right choice where the second device has nothing to do with a messenger and a strictly standard mechanism is required. Veriqa wins where speed and avoiding a repeat sign-in matter. ## Where Veriqa is not the answer * **you need phishing-resistant-grade security** — use passkeys or hardware keys; * **your audience has none of the supported messengers and no email** — the mechanism has nothing to build on; * **you need federated sign-out**: there is no end-session endpoint; * **you need anti-fraud**: Veriqa reports the "sender is a bot" flag, but that is a cheap filter, not a fraud-prevention system. # How a sign-in completes Source: https://veriqa.app/docs/concepts/completion Three ways to learn that the confirmation happened: the redirect back, live status over SignalR and transaction status polling — plus how the Email channel differs. The confirmation happens on the phone while the initiator waits on another device. Hence a question an ordinary OIDC provider never raises: **how does the initiator learn that it happened?** There are three ways, and they are not alternatives but different layers: the redirect back is always mandatory, while live status and polling are about what to show the user while they wait. ## 1. The redirect to `redirect_uri` — the main path The outcome arrives exactly as in any OIDC flow: the initiator's browser returns to `redirect_uri` with an authorization code, and the application exchanges it for tokens. Nothing here is specific to Veriqa. For a desktop web application that is all you need: the sign-in page takes the user back on its own. The return query carries `iss` (RFC 9207). A strict relying party should compare it with its expected issuer — cheap protection against a substituted provider. ## 2. Live status — SignalR While the person picks up their phone, scans the code and taps the button, the initiator can show the state: waiting for confirmation, confirmed, declined. The `/hubs/auth` hub exists for that — the Veriqa sign-in page uses it itself, and it is available to your surface too. This is what makes the wait legible on a TV or a kiosk, where nobody is watching the address bar. ## 3. Status polling — the fallback Where WebSockets are unavailable (a strict proxy, a restricted network, an embedded device), polling remains: ```http theme={null} GET /api/transaction/{id}/status ``` This endpoint is about polling the state of the current sign-in. A confirmation your server creates goes through a separate entry point: `POST /api/transaction/confirmation` creates a confirmation transaction (the application authenticates with client credentials and receives a `transaction_id` and an entry point for the user), and `GET /api/transaction/{id}/result` returns its outcome (application token required). The full contract is in [Server-to-server confirmation API](/docs/reference/confirmation-api). Send `include_channel_entries: true` in the creation request and the response also carries `channel_entries` — a deep link with its QR code for every available channel — next to the single `channel_entry`, which stays as it is. Which channels are listed and in what order comes from the channel display configuration (a `ui_config` record included), not from your request. A channel with no address to open — Email in Pull mode, for one — is not listed, and when confirmation is configured to happen on the web page the array is empty. Showing a single entry from the list is your call. `response_valid_until` is then the earliest moment among all the entries returned. Once the result is `confirmed`, a client the integrator has allowed to do so can also obtain the confirming person's `id_token` — see [linking from your server](/docs/scenarios/channel-linking#linking-from-your-server-without-a-sign-in). ## The Email channel behaves differently An important difference for integrators: **the web confirmation step depends on the channel**. * on **Telegram** the `/connect/authorize/callback` return redirects immediately: the person has already tapped "yes" in the messenger; * on **Email** the same address renders a "Confirm your sign-in" page with a form — no code is issued until it is submitted. The reason: clicking a link in an email does not prove the same person is behind the browser. But an integrator who tuned the flow on Telegram hits an "extra" screen on Email — that is a property of the channel, not a malfunction. The "Confirm your sign-in" page names the account the sign-in is performed with — the channel and an identifier the person recognizes: the email address, the `@username`, or the phone number masked down to its last four digits (`Telegram · +•••••••1234`). The internal channel user ID and the display name are never shown. Whoever holds the page may not be the account's owner, so this line is what lets them refuse a sign-in to someone else's account. Both answers bring the browser back to your `redirect_uri`, even on another domain. The link in the email of a **confirmation transaction** (an action your application asks the person to confirm, not a sign-in) opens a page with a single "Continue" button. Opening the link changes nothing, so a mail scanner that follows links confirms nothing. "Continue" leads to the page that shows what is being confirmed, and the answer is accepted only in the browser that came through the link: a window opened on another device shows a neutral page, and the person answers where they read the email. ## After the confirmation What the window does on success (close, show a message, continue automatically) is a configuration matter — part of the sign-in settings bundle the project owner or the integrator assigns to a client. Receiving the tokens does not depend on it. ## Next How a TV and a kiosk differ from a desktop. What the callback failures mean. # Hop mode Source: https://veriqa.app/docs/concepts/hop-mode A short one-time link in the QR code instead of the channel link: a light QR code, a scan signal on the computer, a launch page on the phone and the context of the scanning device. Why it is better and how to turn it on. In hop mode the QR code on the sign-in page carries a **short one-time link to your Veriqa server** instead of the messenger link. The phone opens it, the server records the scan and sends the user on to the messenger or to email. That one extra hop gives the best user experience Veriqa offers. **We recommend turning hop mode on.** It is off by default only because it needs an issuer set as an absolute `https` address that the phone can reach. See [how to turn it on](#how-to-turn-it-on) below. ## Key differences | | Without hop mode | With hop mode | | - | - | - | | **What the QR code carries** | The channel link — a messenger deep link or an email. It is long, so the QR code is dense | A short link to your server. The QR code is light, any phone camera reads it at once, and its density does not depend on the channel | | **What the computer shows after the scan** | Nothing until the channel answers the start or the confirmation arrives | Right away: "QR code scanned. Continue in Telegram." The channel choice gives way to the instruction "Continue on your phone…" with a "Choose another way" button | | **One QR code for every channel** | No — each channel has its own QR code | Yes: `QrMode: Unified`, and the user picks the channel on the phone | | **If the messenger app does not open** | Nothing to fall back on: the hop to the app is entirely up to the messenger | A launch page on the phone opens the app by itself and keeps a button to try again | | **What is known about the scanning phone** | Nothing | Time, IP, browser, OS and device type — risk signals next to the [initiator context](/docs/concepts/initiator-context) | | **The link reopened from another browser** | Not visible | Flagged — `WarnOnRepeatScan`, on by default | | **What the deployment needs** | Nothing extra | An issuer that is an absolute `https` address the phone can reach | ## What the user sees 1. On a computer, a TV or a kiosk, the user scans the QR code with their phone camera. 2. The phone opens the short link. The sign-in page on the computer immediately says "QR code scanned" and shows what to do next. 3. The phone moves on to the messenger — directly or through a launch page, depending on the [redirect style](#redirect-style). 4. The user confirms with a single tap, and the sign-in completes on the computer. If they scanned with the wrong device or changed their mind, the "Choose another way" button brings the channel choice back on the same page, for the same sign-in. Once the channel has answered the start, the button goes away: the sign-in is now bound to that channel. ## Redirect style | `RedirectStyle` | What the phone gets | | - | - | | `Interstitial` (default) | A launch page: it opens the messenger app by itself and keeps a button in case the app did not open | | `Direct` | A redirect straight to the messenger, with no page in between | `Interstitial` is more reliable where a direct jump into the messenger app does not always work. The Email channel has rules of its own — see [Email under hop mode](/docs/guides/channels#email-under-hop-mode). ## How to turn it on ```json appsettings.json theme={null} { "Veriqa": { "OpenIddict": { "Server": { "Issuer": "https://auth.example.com" } }, "HopMode": { "Enabled": true } } } ``` Turn it on for the whole server, for a single channel (`Veriqa:Channels:{Channel}:HopEnabled`) or for one application, in its OIDC client record. All the keys, and the order in which they override each other, are in the [Veriqa:HopMode](/docs/reference/configuration#veriqahopmode) reference. Every hop link is built on `Veriqa:OpenIddict:Server:Issuer`, so with hop mode on, the issuer must be an absolute `https` address — otherwise the server does not start. To try hop mode on your own machine, expose Veriqa through an HTTPS tunnel — see [Testing the integration locally](/docs/integrations/local-testing#hop-mode-on-a-local-setup). ## Security * **It is not a link shortener.** The server always derives where to send the user from the transaction, never from the link, so a hop link cannot be turned into an open redirect. * **The context of the scanning phone is a risk signal, not an authentication factor.** It helps spot a request that is not the user's own, but the sign-in is still confirmed by the user in their own messenger. The `HopMode.Collect*` keys decide what is collected: IP, browser and device type are on by default; the browser fingerprint and geolocation only if you turn them on. # Initiator context Source: https://veriqa.app/docs/concepts/initiator-context What the confirmation message shows: who initiated the sign-in and from where. A Veriqa sign-in usually starts on the desktop and is confirmed in a trusted channel on the phone. So the user can tell they are confirming **their own** sign-in (and not someone else's attempt), Veriqa adds an **initiator context** to the confirmation message — a short summary of who started the transaction and from where. Initiator context is a hint for the user before confirming, not an access decision. It does not replace security checks: the transaction is still bounded by TTL, one-time use and rate limiting. ## What is shown The set of fields is defined by `DisplayFields`. Available fields: | Field | Meaning | | - | - | | `application` | Name of the client application that started the sign-in (the OIDC client `DisplayName`) | | `browser` | Initiator's browser (from the User-Agent) | | `os` | Initiator's OS / platform (from the User-Agent) | | `region` | Approximate region (city, country) by IP | All four fields are shown by default. ## Where the data comes from The context snapshot is built when the transaction starts (`/connect/authorize`) from the request: * **IP address** — from the connection (best-effort; honors forwarded headers behind a trusted proxy). * **Browser and OS** — by parsing the `User-Agent`. * **Region** — by IP geolocation via an **offline** database (no calls to online services on the trust path). Geolocation requires two things the base packages do not carry: the satellite package `Veriqa.Core.AuthServer.MaxMind` and a local GeoIP database (MaxMind `.mmdb` or compatible). With the package missing or no database configured, the geo fields stay empty and the rest of the context is shown as usual (graceful degradation). The standalone image already carries the package; the database is always yours to mount. The raw `User-Agent` is not stored by default. **Collection is on by default.** `Enabled`, `CollectIpAddress`, `CollectUserAgent` and `CollectGeoLocation` all ship as `true`. What that means in practice — where each value is stored, for how long, and how to switch the collection off — is in [Security and data handling](/docs/guides/data-handling#collection-defaults). ## Configuration The `Veriqa:InitiatorContext` section: ```json appsettings.json theme={null} { "Veriqa": { "InitiatorContext": { "Enabled": true, "CollectIpAddress": true, "CollectUserAgent": true, "CollectGeoLocation": true, "StoreRawUserAgent": false, "DisplayFields": [ "application", "browser", "os", "region" ], "GeoIp": { "Provider": "OfflineDatabase", "DatabasePath": "/data/geoip/GeoLite2-City.mmdb" } } } } ``` * `Enabled` (default `true`) — the global switch. When `false`, no snapshot is built and behavior is unchanged. * `CollectIpAddress` / `CollectUserAgent` / `CollectGeoLocation` — what to collect (all default `true`). * `StoreRawUserAgent` — whether to keep the raw User-Agent (default `false`). * `GeoIp.Provider` — `OfflineDatabase` (offline `.mmdb`); `GeoIp.DatabasePath` — path to the database. The `GeoIp` keys switch nothing on until the application has a provider that reads the database. It arrives as a satellite package — the core pulls no GeoIP library transitively: ```bash theme={null} dotnet add package Veriqa.Core.AuthServer.MaxMind ``` ```csharp theme={null} builder.Services.AddMaxMindGeoIp(); ``` Without it the host starts as usual and, with `CollectGeoLocation: true`, writes a startup warning carrying the code `initiator_context_geoip_unavailable`. **On the standalone image** the package is already included and switched on by `GeoIp.DatabasePath`. The database is not part of the image — MaxMind's EULA covers the data, so you download it yourself and mount it read-only: ```bash theme={null} docker run -d --name veriqa \ -v /srv/geoip/GeoLite2-City.mmdb:/data/geoip/GeoLite2-City.mmdb:ro \ -e Veriqa__InitiatorContext__GeoIp__DatabasePath=/data/geoip/GeoLite2-City.mmdb \ registry.gitlab.com/veriqa/veriqa ``` A path to a missing or unreadable file does not stop the container: the provider logs `GeoIP database is not open` and the geo fields stay empty. ## Per-client configuration Display is configured **per OIDC client** through the client's fields in configuration. A value of `null` means "inherit the global default". An application can only **narrow** the top-level field set, never widen it. ```json appsettings.json theme={null} { "Veriqa": { "OpenIddict": { "Clients": [ { "ClientId": "my-app", "DisplayName": "My Application", "InitiatorContextEnabled": true, "InitiatorContextDisplayFields": [ "application", "region" ] } ] } } } ``` * `InitiatorContextEnabled` — whether to show the context in the confirmation for this client (controls **display**; snapshot collection is governed globally). * `InitiatorContextDisplayFields` — the narrowed field set for this client. ## Next steps Where the initiator context is built in the flow. The pre-production checklist. # OIDC + Veriqa explainer Source: https://veriqa.app/docs/concepts/oidc-explainer What Veriqa does inside a standard OIDC flow, and what it deliberately does not. This explainer is for developers who have worked with OIDC but not with Veriqa. It shows where exactly Veriqa plugs into a standard flow — and where it stops. ## OIDC on one screen OpenID Connect is a thin identity layer on top of OAuth 2.0. The application receives two tokens: an `id_token` (who the user is) and an `access_token` (access to APIs). The app does not check passwords itself — it trusts the **issuer** (authorization server), which returns signed tokens. ## Authorization Code Flow + PKCE The standard secure flow for web and desktop apps: 1. The app redirects the user to the issuer's `/authorize` (with `state`, `nonce` and PKCE parameters). 2. The user authenticates on the issuer side. 3. The issuer returns a short-lived `code` to the registered `redirect_uri`. 4. The app exchanges the `code` (+ PKCE `code_verifier`) for tokens at `/token`. PKCE with the `S256` method is mandatory for public clients — it protects the `code` exchange from interception. ## Where Veriqa fits Veriqa sits **between steps 2 and 3** — in place of "the user authenticates". Instead of a sign-in form, the issuer opens a **Veriqa transaction**: it shows a QR code / entry point, waits for confirmation in a trusted channel, builds a `ClaimsPrincipal` from the result and hands it to OpenIddict to issue the `code`. From there it is plain OIDC (`code` → `/token` → tokens). From starting the transaction to issuing OpenIddict tokens. The key point: Veriqa **does not replace OpenIddict** — token issuance, discovery, JWKS and refresh stay with it. Veriqa only supplies user authentication through a trusted channel. ## The channel adapter in the flow The channel adapter (Telegram / WhatsApp / other channels / Email) links the transaction to a specific messenger: it delivers the confirmation request to the user, receives their response and returns the resolved identity to the transaction. Each channel is a separate adapter, and the set is extensible. ## Resolved identity and claims After confirmation Veriqa builds an identity snapshot and maps it to OIDC claims: | Claim | Meaning | | - | - | | `sub` | Stable subject identifier (required) | | `amr` | Authentication method — the confirmation channel type (`telegram`, `whatsapp`, `max`, `email`). In the `id_token` it is an **array of strings** even for a single method: `"amr": ["telegram"]` (OIDC Core 1.0 §2). The same method is also visible in the `access_token` and the `/connect/userinfo` response | | `auth_time` | When authentication completed | | `name`, `email`, … | Extra claims from the resolved identity — depend on the channel and requested scopes | | `channel_type`, `channel_user_id` | The confirmation channel and the user's id inside it — **only when the `channel` scope is requested** | `channel` is a Veriqa-specific scope and it is the gate for the channel claims. If you do not request it, neither claim reaches the `access_token`, the `id_token` or the `/connect/userinfo` response. Add `channel` to your RP's requested scopes and to the client's `AllowedScopes` if your application reads them. `avatar` is the other Veriqa-specific scope, and it is the gate for `picture`. The value is the image itself, a `data:image/…;base64,…` URI (a channel that exposes a public link may give an `https` URL instead). It travels only in the `access_token` and so reaches you only through `/connect/userinfo`; it is never put into the `id_token`. Request `avatar` (and add it to the client's `AllowedScopes`), fetch the claims from userinfo (`GetClaimsFromUserInfoEndpoint = true` in ASP.NET Core), and treat a missing `picture` as "no avatar". In ASP.NET Core `picture` is not mapped into the principal by default, and it should not be: a data URI in the authentication cookie inflates the request headers. Take the image from the userinfo JSON in `OnUserInformationReceived` and keep it in your own store. `/connect/userinfo` returns only the claims permitted by scopes and configuration. Override the mapping with your own `IClaimsMapper`. `preferred_username` is a hint, not an identity. On the Email channel (Push mode) it carries the sender display name — a string the sender types into the `From` header themselves. SPF, DKIM and DMARC do **not** verify it: they cover the domain and the address, not the displayed name. Veriqa sanitizes the value (bidi overrides and control characters are removed, the length is capped), but it cannot verify it. The user identifier is `sub` alone; the verified address is `email` next to `email_verified`. Never match accounts on `preferred_username` and never present it as a verified name. ## Step-up and confirmation in OIDC terms `2FA` and `step-up` are not separate transaction types but overlays: `2FA` over `login`, `step-up` over `confirmation`. It is the same Veriqa transaction model with extra context, so confirming a sensitive action goes through the same trusted channel as a normal sign-in. ## What Veriqa is NOT Veriqa is **not** an identity provider, **not** an account manager and **not** a registration system. It adds an authentication and linking method to your existing setup without owning your user base. ## Further reading Wire Veriqa into an ASP.NET Core host. The pre-production checklist. # Sign-in methods compared Source: https://veriqa.app/docs/concepts/sign-in-comparison How a password, an SMS code, social login, a passkey and Veriqa compare: what the integrator sets up, what the user does at sign-in and how long it takes — with the source of every figure. What the integrator sets up, and what the user does and how long it takes at the first and at a repeat sign-in. The times of the other methods come from published reports and our own measurements; the Veriqa times are all our own measurements. The setup answers and the effort levels are our assessment, and the rules behind them are below the tables. ## Sign-in: user effort and time Effort is a level from 1 to 5 on the [scale below](#how-user-effort-is-counted). A number in square brackets points to the [source](#sources-for-the-sign-in-times) of the time. | Sign-in method | Scenario | First sign-in | Repeat sign-in | Time: first / repeat | | - | - | - | - | - | | Password | Password only | 5 | 4 | no data / \~30 s [\[3\]](#sources-for-the-sign-in-times) | | Password | With a second factor | 5 | 5 | no data / \~69 s [\[1\]](#sources-for-the-sign-in-times) | | SMS code | — | 4 | 4 | no data / \~17 s [\[4\]](#sources-for-the-sign-in-times) | | Social login | — | 3 | 1 | 30 s / **3.4 s** [\[6\]](#sources-for-the-sign-in-times) | | Passkey | The key is on the same device | 5 | 1 | no data / **3–15 s** [\[1, 2, 3\]](#sources-for-the-sign-in-times) | | Passkey | The key is on the phone, the sign-in is on a computer | 5 | 3 | 24 s / 18 s [\[7\]](#sources-for-the-sign-in-times) | | **Veriqa** | On a computer, automatic sign-in | 1 | 1 | **3.5 s** [\[5\]](#sources-for-the-sign-in-times) | | **Veriqa** | On a computer, with confirmation | 2 | 2 | **6 s** [\[5\]](#sources-for-the-sign-in-times) | | **Veriqa** | On a phone, automatic sign-in | 1 | 1 | **2.4 s** [\[5\]](#sources-for-the-sign-in-times) | | **Veriqa** | On a phone, with confirmation | 1 | 1 | **4 s** [\[5\]](#sources-for-the-sign-in-times) | Times of up to 15 seconds are in bold. With Veriqa the first sign-in takes as long as a repeat one, so the table shows a single time. ## Setup: what the integrator does | Sign-in method | Can an AI agent do the setup? | How much of a person's time does it take? | Is a third party involved? | | - | - | - | - | | Password | The agent writes the code; a person sets up the email provider and DNS | Hours | An email provider, no approval | | SMS code | The agent writes the code; a person signs the contract and registers the sender | Hours of work and days of waiting | An SMS provider and the carriers, with approval | | Social login | The agent writes the code; a person opens the developer accounts and creates the applications | Hours for each provider, plus waiting for the review | Each provider, with a review of the application | | Passkey | The agent writes the code; a person sets up the email provider for the fallback sign-in | Minutes for the passkeys, hours with the fallback sign-in | Not needed for the passkeys; an email provider for the fallback | | **Veriqa** | **The agent does everything except creating the bot** | **15–30 minutes** | The messenger: a Telegram bot is created right away, with no approval | What the setup includes: * **Password** — password storage and hashing, a sign-up form, email confirmation, password reset, an email provider, a second factor, brute-force protection, support for users who forget their password. * **SMS code** — a contract with an SMS provider, sender registration with the carriers, code generation and verification, rate limits and protection against SMS fraud, a charge for every message. * **Social login** — for each provider: a developer account (paid, in Apple's case), an application, a consent screen, redirect URIs, keys, the provider's review of the application, and your own code that handles the response and links accounts. * **Passkey** — a WebAuthn server library, the relying party ID and origin settings, a table for the users' public keys, sign-up and sign-in code on the server and on the front end. The integrator creates no secrets. A fallback sign-in is needed for a lost or new device — usually a link by email, and that takes an email provider. * **Veriqa** — run the container, point it at a database, create a bot and set its token, issue two token certificates, declare an OIDC client, put the service behind a reverse proxy and connect your application as you would to any OIDC provider. Step by step in [Quickstart self-hosted](/docs/quickstart/self-hosted). ## Sources for the sign-in times 1. [Microsoft Learn — Passkeys (FIDO2) authentication method in Microsoft Entra ID](https://learn.microsoft.com/en-us/entra/identity/authentication/concept-authentication-passkeys-fido2). Signing in with a synced passkey takes 3 seconds, versus 69 seconds for a password with a traditional second factor. Data on Microsoft consumer accounts. 2. [FIDO Alliance — Passkey Index, October 2025](https://fidoalliance.org/fido-alliance-launches-passkey-index-revealing-significant-passkey-uptake-and-business-benefits/). A passkey sign-in takes 8.5 seconds on average. Data reported by nine member companies of the alliance. 3. [Google Security Blog — Making authentication faster than ever: passkeys vs. passwords](https://security.googleblog.com/2023/05/making-authentication-faster-than-ever.html). A sign-in takes 14.9 seconds on average with a passkey and 30.4 seconds with a password. Data on about 100 million sign-ins to Google accounts, March–April 2023. 4. [Google for Developers — Mercari's passkey authentication speeds up sign-in 3.9 times](https://developers.google.com/identity/passkeys/case-studies/mercari). A sign-in with an SMS code takes 17 seconds on average. Mercari's data on its own users. 5. Our own measurement, taken in Telegram on a phone where the messenger was already installed and signed in. For a sign-in on a computer, the user opens the camera, points it at the QR code and taps the link; with confirmation they also tap Confirm. In other messengers the time may differ. 6. Our own measurement of sign-in with Google on the Logto sign-in page. The first sign-in took 30 seconds in a browser already signed in to Google: Google still asked for the second factor. A repeat sign-in took 3.4 seconds and one click. In a browser not signed in to Google, the first sign-in also requires typing the account password. 7. Our own measurement of a passkey kept on the phone, on the same Logto sign-in page. Creating the key took 24 seconds after some practice and about a minute the very first time; creating the account is not counted. A repeat sign-in on a computer took 18 seconds. Where the sources give no time for the first sign-in, the table shows only the repeat one. The figures were collected in different ways: a survey of companies, the statistics of one vendor and our own measurement. Read them as an order of magnitude, not as one experiment. ## How user effort is counted We count what the user does in a clean browser, with no password manager: every click, tap, scan and typed field. Scanning a QR code with the phone camera is one action. | Level | What the user does | | - | - | | 1 | One or two actions, nothing to type | | 2 | Three actions, nothing to type | | 3 | Four or more actions, or one field to type | | 4 | Two fields to type | | 5 | Three or more fields to type | The actions in each scenario: | Method and scenario | First sign-in | Repeat sign-in | | - | - | - | | Password only | Type the email and the password twice, then open the mailbox and click the confirmation link. | Type the username and password. | | Password with a second factor | Type the email and the password twice, then open the mailbox and click the confirmation link. | Type the username and password, then open another app and type the second-factor code. | | SMS code | Type the phone number, wait for the SMS, type the code. | Type the phone number, wait for the SMS, type the code. | | Social login | Click the button, complete the social network's second-factor check and accept the consent screen. In a browser not signed in to the social network, the user also types its username and password. | Click the button. | | Passkey, the key is on the same device | Create an account another way, usually with a password or a code, then create the key and confirm with biometrics. | Click the button and confirm with a fingerprint or a face scan. | | Passkey, the key is on the phone, the sign-in is on a computer | Create an account another way, usually with a password or a code, then create the key and confirm with biometrics. | Click the button, choose another device, scan the QR code with the phone and confirm with biometrics on it. Bluetooth has to be on. | | Veriqa on a computer, automatic sign-in | Scan the QR code with the phone camera and tap the link. | The same. | | Veriqa on a computer, with confirmation | Scan the QR code with the phone camera, tap the link and tap Confirm. | The same. | | Veriqa on a phone, automatic sign-in | Tap the messenger button on the sign-in page. | The same. | | Veriqa on a phone, with confirmation | Tap the messenger button on the sign-in page and tap Confirm. | The same. | ## How the setup is assessed Three questions for each method: can an AI agent do the whole setup, how much of a person's time it takes, and whether a third party has to be involved. The answers are our assessment; only the Veriqa time was measured. How Veriqa differs from passkeys, messenger sign-in widgets, SMS codes and the device flow in substance rather than in figures is covered in [How Veriqa differs](/docs/concepts/comparison). # Channel setup Source: https://veriqa.app/docs/guides/channels End-to-end setup for every shipping channel: registering the adapter in code, its configuration section and its webhook. A channel is the trusted app where the user confirms sign-in. Veriqa ships eight adapters — each has its own section below — and the setup is identical whether you run Veriqa [embedded](/docs/quickstart/dotnet) or [self-hosted](/docs/quickstart/self-hosted). Every channel follows the same three steps: register the adapter in code, fill in its `Veriqa:Channels:{Channel}` configuration section, and make its webhook reachable from the internet. ```csharp Program.cs theme={null} adapters.AddTelegram(); adapters.AddWhatsApp(); adapters.AddMax(); adapters.AddInstagram(); adapters.AddMessenger(); adapters.AddWeChat(); adapters.AddViber(); adapters.AddEmail(); ``` **Every channel is its own package.** The base set installs as one metapackage, `Veriqa.Core.BaseChannels` (Telegram, WhatsApp, Email); MAX, Instagram, Messenger, WeChat and Viber are not part of it and are installed explicitly — `dotnet add package Veriqa.Core.ChannelAdapter.Max`, `dotnet add package Veriqa.Core.ChannelAdapter.Instagram`, `dotnet add package Veriqa.Core.ChannelAdapter.Messenger`, `dotnet add package Veriqa.Core.ChannelAdapter.WeChat`, `dotnet add package Veriqa.Core.ChannelAdapter.Viber`. Need a single channel? Install just that package (`Veriqa.Core.ChannelAdapter.Telegram`, `.WhatsApp`, `.Max`, `.Instagram`, `.Messenger`, `.WeChat`, `.Viber`, `.Email`): only its own SDK comes with it, so a mail library never lands in a deployment that signs users in through Telegram. The registration call does not depend on that choice — `adapters.AddTelegram()` is written the same either way. Calling `Add*()` without the channel's package is a compile error, not a silently missing channel. Registering an adapter is not the same as enabling it. An adapter activates only when its section carries `"Enabled": true` — otherwise only its options and startup validator are registered, and the channel never appears on the sign-in page. Add only the channels you use. Bot tokens, access tokens and SMTP passwords are secrets. Keep them in user-secrets during development and in a secret store in production — never in a committed `appsettings.json`. ## Inbound verification level Every channel section carries a required `InboundVerification` key — it declares **how the channel verifies that an incoming event is authentic**. You declare the fact: Veriqa only sees that a check ran and passed, never what the check was. A deployment where a shipped channel is enabled and the value is missing or not one of the allowed values **does not start**. State the value in the sections of disabled channels too — then flipping `Enabled` to `true` does not break startup. The startup check covers the same set of channels the core builds its list of enabled channels from. All shipped channels are in it; a channel from a third-party adapter plugged in with a bare `AddChannel` call is not, so the key in its section is required but not checked at startup — see [Your own channel adapter](/docs/guides/custom-channel-adapter). The values of the shipped channels follow the mode you run them in: | Channel | Mode | Value | Provided by | | - | - | - | - | | Telegram | `UpdateMode: Webhook` | `shared_secret` | The `X-Telegram-Bot-Api-Secret-Token` header secret, compared in constant time | | Telegram | `UpdateMode: Polling` | `outbound_fetch` | There are no incoming requests: the service fetches the updates itself | | MAX | `UpdateMode: Webhook` | `shared_secret` | The `X-Max-Bot-Api-Secret` header secret, hashes compared in constant time | | MAX | `UpdateMode: Polling` | `outbound_fetch` | There are no incoming requests: the service fetches the updates itself | | WhatsApp (Meta Cloud API) | webhook | `signature` | HMAC-SHA256 of the request body, header `X-Hub-Signature-256` | | WhatsApp (Twilio) | webhook | `signature` | HMAC-SHA1 of the webhook URL and the sorted form parameters, header `X-Twilio-Signature` | | Instagram | webhook | `signature` | HMAC-SHA256 of the request body, header `X-Hub-Signature-256` | | Messenger | webhook | `signature` | HMAC-SHA256 of the request body, header `X-Hub-Signature-256` | | WeChat | webhook | `shared_secret` | SHA-1 over the account's Token, the timestamp and the nonce, query parameter `signature`, compared in constant time | | Viber | webhook | `signature` | HMAC-SHA256 of the request body keyed with the bot token, header `X-Viber-Content-Signature`, compared in constant time | | Email | `PushEnabled: true`, `Inbound:Provider: Webhook` | `shared_secret` | The incoming webhook secret is checked by the adapter's own endpoints | | Email | `PushEnabled: true`, `Inbound:Provider: ImapPolling` | `outbound_fetch` | There are no incoming requests: the service reads the mailbox itself | | Email | `PushEnabled: false` (default) | `shared_secret` | Pull mode receives no incoming request from a platform at all; the value is kept the same as in Push mode | A channel in `Polling` mode — Email reading its mailbox over IMAP included — that declares anything other than `outbound_fetch` produces a `Warning` at startup; startup continues. The full list of values is in the [configuration reference](/docs/reference/configuration). ## Settings every chat channel shares Two optional keys can be added to the section of any channel — a shipped one, a channel of your own adapter or an HTTP channel. Neither has to be set: a deployment that says nothing keeps the behavior described on this page. | Key | Default | What it does | | - | - | - | | `SpareStartCodeEnabled` | `true` | Turns the channel's spare start code on or off — the short code the sign-in page offers when the link opened the chat and nothing started there. With `false` no code is prepared for the channel and no page shows its block. It applies to a channel that keeps a working link and declares the code as a second way to start — Instagram and Messenger among the shipped ones; a channel whose only way to start is the code is not affected by it | | `BotName` | not set | The name the user sees the chat under — a bot username, an account handle, the name of a Page. It is shown as written, with nothing put in front, and is not translated. A name set here wins over the name the channel reports by itself. A name set in the host configuration also applies to a tenant that runs its own bot until that tenant sets its own name, so that tenant's pages would name the wrong bot: where tenants have their own bots, set the name for each tenant or do not set it at all — the channel then reports it if it can, otherwise the texts go without a name. A blank value means "not set". A name longer than 32 characters is ignored with a warning in the log | ```json appsettings.json theme={null} { "Veriqa": { "Channels": { "Messenger": { "BotName": "Acme Support", "SpareStartCodeEnabled": true } } } } ``` Both keys are set at the same level as the channel's credentials: in the host configuration or for a single tenant. The name appears in two texts on the pages — the caption over the spare code, "Send the code in the chat with the bot Acme Support if the bot has not sent you a confirmation", and the line that tells the user to confirm in the chat. The sign-in page puts that line on its status line for every chat channel once the bot has answered the start of a sign-in: the user is in the conversation, and the answer is now awaited there. Without a name both texts are worded without one; the name of the channel is never put in its place. Switching the spare code off stops the pages from offering it and stops a code from being issued on behalf of that channel. It does not make the channel refuse a code: a transaction has one code, and a code issued for another channel of the same sign-in is still accepted there. ## Telegram **What you need:** a bot created via [@BotFather](https://t.me/BotFather) and its token. ```json appsettings.json theme={null} { "Veriqa": { "Channels": { "Telegram": { "Enabled": true, "InboundVerification": "shared_secret", "BotToken": "YOUR_BOT_TOKEN", "BotUsername": "your_login_bot", "UpdateMode": "Webhook", "WebhookBaseUrl": "https://auth.your-domain.com", "WebhookSecretToken": "YOUR_RANDOM_SECRET" } } } } ``` | Key | Notes | | - | - | | `UpdateMode` | `Webhook` (default) or `Polling` | | `WebhookBaseUrl` | Public HTTPS base URL of the Veriqa host — required in `Webhook` mode | | `WebhookSecretToken` | Arbitrary random string; Telegram echoes it back in every update | | `BotUsername` | Used to build the `t.me` deep link behind the QR code | | `DeepLinkBaseUrl` | Defaults to `https://t.me/`; override only for a custom deep-link host | In `Webhook` mode Veriqa **registers the webhook itself** on startup (and removes it on shutdown) — you do not call `setWebhook` by hand. The endpoint is `POST {WebhookBaseUrl}/api/channels/telegram/webhook`, and each request is authenticated by the `X-Telegram-Bot-Api-Secret-Token` header against `WebhookSecretToken`. `Polling` mode needs no public URL, which makes it the practical choice for local development — switch to `Webhook` for anything deployed. It also allows only **one poller per bot**: the Bot API hands the update queue to a single `getUpdates` caller, so a second process on the same token receives nothing while its polling loop repeats the `409` refusal of the Bot API — logged as `Conflict: terminated by other getUpdates request`, without the number. The health report does not catch this — the token is valid, so the channel reports itself `healthy`; see [running more than one instance](/docs/quickstart/self-hosted#running-more-than-one-instance). Telegram does not share the user's email address, and gives the phone number only when Veriqa asks for it in the chat. Both can be collected inside the sign-in: [Phone number](/docs/guides/phone-number), [Progressive claim enrichment](/docs/concepts/claim-enrichment). ## WhatsApp **What you need** (Meta Cloud API, the default provider): a Meta app with WhatsApp Business, a phone number ID and a permanent access token from the [Meta for Developers](https://developers.facebook.com/) console. Delivery through Twilio is set up [below](#delivery-through-twilio). ```json appsettings.json theme={null} { "Veriqa": { "Channels": { "WhatsApp": { "Enabled": true, "InboundVerification": "signature", "Provider": "MetaCloudApi", "BusinessPhoneNumber": "+15551234567", "MetaCloudApi": { "PhoneNumberId": "YOUR_PHONE_NUMBER_ID", "AccessToken": "YOUR_ACCESS_TOKEN", "AppSecret": "YOUR_APP_SECRET", "WebhookVerifyToken": "YOUR_VERIFY_TOKEN", "GraphApiVersion": "v26.0" } } } } } ``` `BusinessPhoneNumber` is the number users message, in E.164 format — it is what the deep link and QR code point at. Unlike Telegram, the webhook is registered **on Meta's side**. In the app's WhatsApp → Configuration panel set the callback URL to `https://auth.your-domain.com/api/channels/whatsapp/webhook`, paste the same `WebhookVerifyToken`, and subscribe to the `messages` field. Meta verifies the URL with a `GET` carrying `hub.mode` and `hub.verify_token`; Veriqa answers it automatically once the channel is enabled and the host is publicly reachable. Every subsequent `POST` is signed by Meta with `X-Hub-Signature-256` and validated against `AppSecret` — a missing or wrong `AppSecret` means valid deliveries get rejected. Two delivery providers ship with the channel: the **Meta Cloud API** (`MetaCloudApi`, the default) and **Twilio** (`Twilio`). The `Provider` key picks which of them is registered and declares which provider the installation expects; it is matched at startup against the one actually registered. A provider of your own plugged in with `UseWhatsAppProvider()` wins over the shipped one, and its code goes into `Provider`. A mismatch stops the host with an error naming both codes. Such a provider must fill `SenderUserId` of every inbound message with the sender's business-scoped user ID (BSUID) — a message without it is rejected — and it receives the BSUID as the recipient of every outbound message. ### Delivery through Twilio **What you need:** a Twilio account (Account SID and Auth Token), a WhatsApp sender — the Twilio Sandbox for WhatsApp to try it out, a registered sender in production — and a `twilio/quick-reply` content created in the Twilio Content Template Builder. ```json appsettings.json theme={null} { "Veriqa": { "Channels": { "WhatsApp": { "Enabled": true, "InboundVerification": "signature", "Provider": "Twilio", "BusinessPhoneNumber": "+15551234567", "Twilio": { "AccountSid": "YOUR_ACCOUNT_SID", "AuthToken": "YOUR_AUTH_TOKEN", "QuickReplyContentSid": "YOUR_CONTENT_SID", "WebhookUrl": "https://auth.your-domain.com/api/channels/whatsapp/webhook" } } } } } ``` `BusinessPhoneNumber` is the Twilio sender number (the sandbox number when you use the Sandbox). `AccountSid`, `AuthToken`, `QuickReplyContentSid` and `WebhookUrl` are required when `Provider` is `Twilio`: without any of them the host does not start, and the error names the key, never its value. `ApiBaseUrl` is optional and defaults to `https://api.twilio.com`. A tenant keeps its Twilio credentials in the same WhatsApp credentials group as the Meta ones. **The quick-reply content.** The confirmation prompt is sent as one content with two buttons. Create it with the `twilio/quick-reply` type and exactly these variables — Veriqa fills them in: | Variable | Filled with | | - | - | | `1` | the prompt text (up to 1024 characters; a longer one is not sent) | | `2` | the title of the confirm button (truncated to 20 characters) | | `3` | the id of the confirm button | | `4` | the title of the decline button (truncated to 20 characters) | | `5` | the id of the decline button | ```json twilio/quick-reply theme={null} { "twilio/quick-reply": { "body": "{{1}}", "actions": [ { "title": "{{2}}", "id": "{{3}}" }, { "title": "{{4}}", "id": "{{5}}" } ] } } ``` The prompt answers the user's own message, so it goes out inside the 24-hour session and needs no WhatsApp template approval. Do not submit this content for approval. **The webhook.** In the Twilio console set the sender's incoming-message URL (for the Sandbox: *When a message comes in*, method `POST`) to `https://auth.your-domain.com/api/channels/whatsapp/webhook`. Twilio signs every request with `X-Twilio-Signature`, computed over the URL **it** knows, so `WebhookUrl` must be that same URL character for character — scheme, host, port, path and query. Behind a proxy Veriqa sees its own internal address, which is why the URL comes from configuration and not from the request; a mismatch rejects every inbound message with `403`. A tenant with its own webhook route sets its own `WebhookUrl`. Twilio has no `GET` handshake: a `GET` to the webhook is rejected. With the Sandbox, each test phone joins it once by sending `join ` to the sandbox number; the keyword is shown in the Twilio console. This provider runs inside Veriqa. The `node/custom-channel-whatsapp-twilio` sample is a different thing — an external adapter running as a separate service under its own channel type, see [An HTTP channel adapter in any language](/docs/guides/custom-channel-adapter#an-http-channel-adapter-in-any-language). ### The prefilled message text WhatsApp is the only shipped channel whose deep link carries a **message text** rather than an opaque parameter: the user sees it in the input box and sends it as an ordinary message. Out of the box that is a short multi-line message with the sign-in code on its own last line: ```text Base text (en) theme={null} Sign in to Acme Send this message to sign in. Access code auth_7Ks9mR3xYpL2nWq4tBvC8dEfG1hJkM5nP0qRsTuVwXy ``` The text is configured where every other channel text lives — in your host's locale files (`wwwroot/locales/{language}.json`, see [Localization](/docs/guides/customization#localization)). There are two keys: the full variant with the application name and a fallback without it, used when the application name is unavailable. There is no text section under `Veriqa:Channels:WhatsApp`. ```json wwwroot/locales/en.json theme={null} { "Sign in to {app}\nSend this message to sign in.\n\nAccess code\n{code}": "Sign in to {app}\nSend this message to sign in.\n\nAccess code\n{code}", "Send this message to sign in.\n\nAccess code\n{code}": "Send this message to sign in.\n\nAccess code\n{code}" } ``` English is the base language, so in `en.json` the value repeats the key; a translated file such as `ru.json` carries the same two keys with translated values. Your own wording goes into the values of these keys (for English, edit `en.json`). Two slots are available: `{app}` — the application being signed into — and `{code}`, the `auth_…` sign-in code. The language follows the sign-in page, so translate every language you ship. The application name comes from your OIDC client registration and is substituted **as is** — Veriqa does not shorten it (the general slot value limit is 128 characters). Keep the name short: a long one breaks the message layout and goes into the QR code in full, making it denser (see the warning below). **Text length directly affects how readable the QR code is.** The QR encodes the whole `wa.me` URL, and the text inside it is percent-encoded: a space or a line break costs 3 characters, a Cyrillic letter 6, a box-drawing character such as `═` 9. The longer the text, the higher the QR version and the smaller its modules at the same on-screen size. Hence the layout rules worth following: * **keep the code on its own last line** — it is 48 characters long (`auth_` plus a 43-character transaction id) and fits inside no frame; * **do not use `*`, `_`, `~`** as graphics — WhatsApp reads them as markup (bold, italic, strikethrough). `-`, `.`, `:`, `+`, `|` are safe; * **frames with vertical bars** (`|…|`) only line up inside a monospace block (triple backticks) — in an ordinary message the font is proportional and the frame falls apart. Some clients also show the raw backticks in the input box and only render monospace in the sent message, so verify such a layout on a device before rolling it out; * **horizontal rules** (`------`) work in every client and need no wrapper. Rough cost of a layout (the QR generator Veriqa ships; an 11-digit phone number, a 43-character transaction id, error correction level M, application name "Acme Corp"). The "px per module" column is the size of a single QR module at the sign-in page's normal 220 px box. The scanning threshold is **2.86 px per module**. Rows are tagged with the language of the text — a non-Latin language costs far more per character: | Layout | Text language | URL length | QR version | px/module @220 | Fit for a QR code | | - | - | - | - | - | - | | No graphics — the default | English | 171 | v9 | 3.61 | Yes, with headroom | | No graphics — the default | Russian | 388 | v13 | 2.86 | Yes, exactly at the threshold | | Dashed rules + centered heading | Russian | 439 | v13 | 2.86 | Yes, exactly at the threshold | | Dashed rules | Russian | 520 | v15 | 2.59 | No — below the threshold | | `+--+` frame | English | 355 | v13 | 2.86 | No — the frame needs a monospace block | | `+--+` frame | Russian | 682 | v17 | 2.37 | No | | Unicode `╔══╗` frame | Russian | 1147 | v23 | 1.88 | No | The Russian graphics-free and ruled-heading layouts sit **exactly at the threshold** with no headroom left: any extra text raises the QR version and pushes the code out of spec. The English default clears it comfortably. Framed layouts fail two gates at once: the Russian frames also miss the density threshold (2.37 and 1.88), and every frame needs a monospace block — even the English one, which sits exactly at the threshold, falls apart in an ordinary message. Hence the "No" in the column. The practical takeaway: if the message is also shown as a QR code, stay with the graphics-free variant or limit yourself to horizontal rules — frames and box drawing make the QR hard for a camera. Russian text costs roughly twice as much as English at the same layout: the same default text is 388 characters against 171. Ready-made samples of every layout follow; `{app}` and `{code}` are filled in by Veriqa. ```text No graphics — the default theme={null} Sign in to {app} Send this message to sign in. Access code {code} ``` ```text Dashed rules + centered heading theme={null} ------------------------ SIGN IN TO {app} ------------------------ Just send this message. Access code: {code} ``` ```text Dashed rules theme={null} ------------------------ SIGN IN TO {app} ------------------------ Send this message to confirm the sign-in. Access code {code} ``` Framed layouts need a monospace block — the text itself is wrapped in triple backticks and the code goes below the block (it does not fit inside a frame): ````text The "+--+" frame — only after checking it on a device theme={null} ``` +----------------------+ | SIGN IN TO {app} | +----------------------+ | Just send this | | message to confirm. | +----------------------+ ``` Access code {code} ```` ````text Unicode frame — the most expensive one, unfit for a QR code theme={null} ``` ╔══════════════════════╗ ║ SIGN IN TO {app} ║ ╠══════════════════════╣ ║ Just send this ║ ║ message. ║ ╚══════════════════════╝ ``` Access code {code} ```` The chosen sample goes into the locale file as a single line — line breaks are written as `\n`: ```json wwwroot/locales/en.json — the dashed-rules layout theme={null} { "Sign in to {app}\nSend this message to sign in.\n\nAccess code\n{code}": "------------------------\n SIGN IN TO {app}\n------------------------\nJust send this message.\n\nAccess code:\n{code}", "Send this message to sign in.\n\nAccess code\n{code}": "------------------------\nJust send this message to sign in.\n------------------------\n\nAccess code:\n{code}" } ``` Restyle **both** keys at once. The second one is the fallback without the application name; if you only restyle the first, users whose application name is unavailable will see a different message. Alignment around `{app}` is approximate: application names differ in length, so neither space-centering nor the right edge of a frame lines up exactly. If you need a frame to look even, use the variant without the application name. ## MAX **What you need:** a MAX bot and its token. Configuration mirrors Telegram: ```json appsettings.json theme={null} { "Veriqa": { "Channels": { "Max": { "Enabled": true, "InboundVerification": "shared_secret", "BotToken": "YOUR_BOT_TOKEN", "BotPublicName": "Your Login Bot", "UpdateMode": "Webhook", "WebhookBaseUrl": "https://auth.your-domain.com", "WebhookSecretToken": "YOUR_RANDOM_SECRET" } } } } ``` As with Telegram, Veriqa registers the webhook on startup in `Webhook` mode. On shutdown it removes its own subscription — the one for the address it registered. The endpoint is `POST {WebhookBaseUrl}/api/channels/max/webhook`, authenticated by the `X-Max-Bot-Api-Secret` header. `BotPublicName` is the name shown to the user on the sign-in page. ## Instagram The user confirms the sign-in in Instagram Direct, in a conversation with your Instagram account. The channel runs on the Instagram API with Instagram Login and talks plain HTTP to `graph.instagram.com` — the package carries no platform SDK. **What you need:** an Instagram [professional account](https://developers.facebook.com/docs/instagram-platform/overview) (business or creator), a Meta app from the [Meta for Developers](https://developers.facebook.com/) console with that account connected, and from the console — the app secret, an access token of the account, the account's ID and its username. ```json appsettings.json theme={null} { "Veriqa": { "Channels": { "Instagram": { "Enabled": true, "InboundVerification": "signature", "AppSecret": "YOUR_APP_SECRET", "WebhookVerifyToken": "YOUR_VERIFY_TOKEN", "AccessToken": "YOUR_ACCESS_TOKEN", "AccountId": "YOUR_INSTAGRAM_ACCOUNT_ID", "Username": "your_login_account", "GraphApiBaseUrl": "https://graph.instagram.com", "GraphApiVersion": "v26.0" } } } } ``` | Key | Notes | | - | - | | `AppSecret` | The app secret of the Meta app; every incoming delivery is checked against it | | `WebhookVerifyToken` | Arbitrary random string; you paste the same value into the webhook settings of the Meta app | | `AccessToken` | Access token of the Instagram account the messages are sent from | | `AccountId` | ID of that account — the sender of every message | | `Username` | Username of that account; the `ig.me` link behind the QR code and the button is built from it. A leading `@` is dropped | | `GraphApiBaseUrl` | Defaults to `https://graph.instagram.com`; must be an absolute HTTPS URL | | `GraphApiVersion` | Defaults to `v26.0`; must start with `v` | With `"Enabled": true`, `AppSecret`, `WebhookVerifyToken`, `AccessToken`, `AccountId` and `Username` are required: a deployment with an empty one does not start, and the error message names the key. `GraphApiBaseUrl` and `GraphApiVersion` have defaults; they are settings of the installation, and the five required keys are the credentials of a tenant. The sign-in link is `https://ig.me/m/{Username}?ref=auth_{transactionId}`. It opens the conversation with your account, Instagram hands the `ref` value to Veriqa, and the user gets the question with the Confirm and Decline buttons; the outcome arrives as a message of its own. **When the `ref` value does not arrive,** the sign-in has a second way to start. The sign-in page shows a short code below the Instagram button and QR code; the user sends it to your account as a message — the digits alone, with or without spaces — and the sign-in starts as it does from the link. A code that matches no sign-in — mistyped, expired or already used — gets the same answer whatever the reason: check the code on the sign-in page and send it again, or open the sign-in page again. **The code appears when it may be needed,** not as soon as the page opens, and only while your account has not answered. Where it appears depends on the device the sign-in page is open on: * **A computer or a tablet** shows the code 8 seconds after the page loads — unless the Instagram QR code on the page is a [hop mode](/docs/reference/configuration#veriqahopmode) QR code. Then the code waits until the phone opens Instagram, and the sign-in page shows it under the instruction to continue on the phone 6 seconds later. * **On a phone,** the code does not open by itself. Once the user has tapped the Instagram button and come back to the page, a **Problem signing in** link appears under the status line, for the channel tapped last; the link opens the code. A reload forgets the tap. * **The page a phone opens from a [hop mode](/docs/reference/configuration#veriqahopmode) QR code that covers several channels** leaves the choice of channel to that page. Tapping the Instagram button switches the page to that channel: the title and the instruction change to "Continue in Instagram" with the channel icon, the channel buttons disappear, and a **Back to channel selection** link below brings the choice back without affecting the sign-in. When the user comes back from the app, the **Problem signing in** link appears under the status line, and the code behind it comes with **Copy and open Instagram**, as on the launch page. Once your account answers or the sign-in ends, the way back to the choice is gone for good. * **The launch page on the phone** under hop mode offers the code behind the same link 6 seconds after it took the user into the app. There the code comes with one button, **Copy and open Instagram**: it copies the code and opens the app. Once the code is opened, the Instagram button above it disappears from the page. A browser with JavaScript turned off shows the code from the start. **The code goes away once your account answers.** As soon as the user is in the conversation and the question with the buttons is on its way, the sign-in page hides the code and its link, does not offer them again, and its status line tells the user to confirm in the chat — "You have moved to Instagram. Tap the confirmation button in the chat with @your\_account if you want to continue signing in, or tap cancel." The channel choice leaves the page with them — the QR codes, the buttons and the instruction to continue on the phone: another channel could only lead into a refusal now. The title, the status line, the countdown and **Cancel sign-in** stay. The same happens when the user comes back to a page left open on the same phone. The sign-in page learns of the answer over its live connection; a page that fell back to polling keeps the code and the channel choice on screen. The launch page on the phone learns of the answer from the server while it is open and takes the link and the code away for good; its Instagram button stays unless the code was opened. The code is switched off altogether by [`SpareStartCodeEnabled`](#settings-every-chat-channel-shares). **A tapped button that names no sign-in** — an Icebreaker tapped with no link behind it — is answered with a hint saying exactly that: send the code shown on the sign-in page, or scan the QR code again. A typed message that is neither a code nor a sign-in marker gets no answer: a person writing to your account about something else is not asking to sign in. To switch the hint off, add `"UnaddressedReplyEnabled": false` to the channel section: your account then stays silent on such a tap, and the code keeps working. The key is optional and defaults to `true`. Both answers are texts of the channel like any other — reword them in the [locale files](/docs/guides/customization#localization). **The webhook** is registered on Meta's side, as with WhatsApp. In the Meta app set the callback URL to `https://auth.your-domain.com/api/channels/instagram/webhook`, paste the same `WebhookVerifyToken`, and subscribe to the fields `messages`, `messaging_postbacks` and `messaging_referral`. Meta verifies the URL with a `GET` carrying `hub.mode`, `hub.verify_token` and `hub.challenge`; Veriqa answers it automatically once the channel is enabled and the host is publicly reachable. Every subsequent `POST` is signed with `X-Hub-Signature-256` — HMAC-SHA256 of the request body keyed with the app secret — and a delivery with a missing or wrong signature is rejected ([Meta: webhooks](https://developers.facebook.com/docs/instagram-platform/webhooks)). **Permissions:** `instagram_business_basic` and `instagram_business_manage_messages` ([Meta: send messages](https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/messaging-api)). **A tenant with a Meta app of its own** gets the same address with its name as the last segment: `https://auth.your-domain.com/api/channels/instagram/webhook/{tenant}`. Without the segment the delivery belongs to the default tenant; a delivery for a tenant the channel is not enabled for gets a `403` response. One Meta app serving the accounts of several tenants is not supported: the tenant is read from the address, not from the account the event came to. **Access is granted by Meta, not by Veriqa.** With the default Standard Access the app serves only accounts you own or manage and users who have a role on the app. Signing in arbitrary users needs Advanced Access, which takes Meta App Review and Business Verification — a verified legal entity ([Meta: Instagram Platform overview](https://developers.facebook.com/docs/instagram-platform/overview)). Meta sends webhook notifications only to an app set to **Live** ([Meta: webhooks](https://developers.facebook.com/docs/instagram-platform/webhooks)). Platform limitations: * **The 24-hour window.** The account may write to a user only within 24 hours of that user's last message to it ([Meta: send messages](https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/messaging-api)). A sign-in fits inside it: every message of the flow answers a user who has just opened the link. * **The referral may not arrive while the Meta app is in development mode.** The user opens the `ig.me` link, and Instagram delivers nothing to the webhook. This is a limit of the platform, not of the adapter. The sign-in then starts with the code shown on the sign-in page: the user sends it to the account as a message. * **The first tap of a new user may arrive without the `ref` value.** When the user has never written to your account, opening the `ig.me` link delivers nothing by itself: Instagram reports the link together with the user's first action there — a tapped Icebreaker or a sent message ([Meta: ig.me links](https://developers.facebook.com/documentation/business-messaging/instagram-messaging/features/ig-me-links)). Set up Icebreakers for the account before you offer the channel, so that a new user has a button to tap — but that first tap may still reach Veriqa without the `ref` value. The user then gets the hint (unless you switched it off) and goes on with the code shown on the sign-in page, or scans the QR code again: the conversation exists by then, and Instagram reports the link at once. The link works in the Instagram mobile app only. * **The sign-in link does nothing from the open conversation with the account.** When the user already has the conversation with your account open in the Instagram app, the `ig.me` link just returns them to it and the `ref` value never reaches Veriqa — the sign-in does not start. From any other screen of the app the link works. The user only has to send the code shown on the sign-in page into that conversation, or leave the conversation and open the link again. Hop mode and its redirect style make no difference. Observed on Instagram for iPhone in October 2026; the behavior belongs to the platform and may change. * **One event per delivery.** When Meta packs several events into one webhook delivery, Veriqa processes one of them and ignores the rest: an event that names a sign-in (an opened link, a pressed button) comes before a shared phone number, and that before any other message. * **The text of the question is truncated to 640 characters** — the limit of a message with buttons ([Meta: button template](https://developers.facebook.com/docs/instagram-platform/instagram-api-with-instagram-login/messaging-api/button-template)). * **No user language.** Instagram does not tell the adapter the user's language, so the messages follow the language of the sign-in. * **The phone number** is asked for with a button that Instagram shows only in its mobile app and only to a user whose profile has a phone number — see [phone number](/docs/guides/phone-number#instagram). ## Messenger The user confirms the sign-in in Messenger, in a conversation with your Facebook Page. The channel runs on the Messenger Platform and talks plain HTTP to `graph.facebook.com` — the package carries no platform SDK. **What you need:** a Facebook Page, a Meta app from the [Meta for Developers](https://developers.facebook.com/) console with that Page connected, and from the console — the app secret, a Page access token, the Page's ID and its username. ```json appsettings.json theme={null} { "Veriqa": { "Channels": { "Messenger": { "Enabled": true, "InboundVerification": "signature", "PageId": "YOUR_PAGE_ID", "PageName": "your.page.username", "PageAccessToken": "YOUR_PAGE_ACCESS_TOKEN", "AppSecret": "YOUR_APP_SECRET", "WebhookVerifyToken": "YOUR_VERIFY_TOKEN", "GraphApiBaseUrl": "https://graph.facebook.com", "GraphApiVersion": "v26.0" } } } } ``` | Key | Notes | | - | - | | `PageId` | ID of the Facebook Page — the sender of every message | | `PageName` | Username of that Page; the `m.me` link behind the QR code and the button is built from it. A leading `@` is dropped. Not required with `DeepLinkByPageId` switched on | | `DeepLinkByPageId` | Optional, defaults to `false`. With `true` the link is built from `PageId` instead of `PageName` — see below | | `PageAccessToken` | Page access token the messages are sent with | | `AppSecret` | The app secret of the Meta app; every incoming delivery is checked against it | | `WebhookVerifyToken` | Arbitrary random string; you paste the same value into the webhook settings of the Meta app | | `GraphApiBaseUrl` | Defaults to `https://graph.facebook.com`; must be an absolute HTTPS URL | | `GraphApiVersion` | Defaults to `v26.0`; must start with `v` | With `"Enabled": true`, `PageId`, `PageAccessToken`, `AppSecret`, `WebhookVerifyToken` and `GraphApiVersion` are required, `PageName` is required unless `DeepLinkByPageId` is on, and so is `InboundVerification` — its value for Messenger is `signature`: a deployment with an empty one does not start, and the error message names the key. `GraphApiBaseUrl` has a default. `GraphApiBaseUrl` and `GraphApiVersion` are settings of the installation; `PageId`, `PageName`, `PageAccessToken`, `AppSecret` and `WebhookVerifyToken` are the credentials of a tenant. The sign-in link is `https://m.me/{PageName}?ref=auth_{transactionId}`. It opens the conversation with your Page, Messenger hands the `ref` value to Veriqa, and the user gets the question with the Confirm and Decline buttons; the outcome arrives as a message of its own. In a conversation that already exists the `ref` value arrives as a referral event; in a new one Messenger reports it with the tap of the Get Started button ([Meta: m.me links](https://developers.facebook.com/docs/messenger-platform/discovery/m-me-links)) — and that first tap may reach Veriqa without the `ref` value. **If the link opens the app store instead of the conversation,** add `"DeepLinkByPageId": true` to the channel section. The link becomes `https://m.me/{PageId}?ref=auth_{transactionId}`, and `PageName` is no longer needed. The username link is the only form Meta documents, so it stays the default; the Page ID link is for a Page whose username link does not open the conversation in the Messenger app — observed on an iPhone with Messenger installed in October 2026. **When the `ref` value does not arrive,** the sign-in has a second way to start. The sign-in page shows a short code below the Messenger button and QR code; the user sends it to your Page as a message — the digits alone, with or without spaces — and the sign-in starts as it does from the link. A code that matches no sign-in — mistyped, expired or already used — gets the same answer whatever the reason: check the code on the sign-in page and send it again, or open the sign-in page again. The code appears and goes away on the conditions described for [Instagram](#instagram): it is shown when it may be needed and hidden once your Page answers. **On an iPhone, the way into Messenger may go through a browser page.** Under hop mode, once the page notices that the phone has opened Messenger, the instruction to continue on the phone on the sign-in page gets one more line: "On iPhone, a browser page with two buttons may open first. Tap the one that opens Messenger, then confirm that you want to open the Messenger app." The sign-in page does not know the phone's operating system, so the line is worded for an iPhone and shown for any phone; it disappears together with the "Choose another way" button and once your Page answers. The launch page on the phone, which does know the operating system, gives the same advice to an iPhone only. **A tapped button that names no sign-in** — Get Started tapped with no link behind it — is answered with a hint saying exactly that: send the code shown on the sign-in page, or scan the QR code again. A typed message that is neither a code nor a sign-in marker gets no answer: a person writing to your Page about something else is not asking to sign in. To switch the hint off, add `"UnaddressedReplyEnabled": false` to the channel section: your Page then stays silent on such a tap, and the code keeps working. The key is optional and defaults to `true`. Both answers are texts of the channel like any other — reword them in the [locale files](/docs/guides/customization#localization). **The webhook** is registered on Meta's side, as with WhatsApp. In the Meta app set the callback URL to `https://auth.your-domain.com/api/channels/messenger/webhook`, paste the same `WebhookVerifyToken`, and subscribe the Page to the fields `messages`, `messaging_postbacks` and `messaging_referrals` — the last two are what delivers the `ref` of an `m.me` link ([Meta: messaging\_referrals](https://developers.facebook.com/docs/messenger-platform/reference/webhook-events/messaging_referrals)). Meta verifies the URL with a `GET` carrying `hub.mode`, `hub.verify_token` and `hub.challenge`; Veriqa answers it automatically once the channel is enabled and the host is publicly reachable. Every subsequent `POST` is signed with `X-Hub-Signature-256` — HMAC-SHA256 of the request body keyed with the app secret — and a delivery with a missing or wrong signature is rejected. **A tenant with a Meta app of its own** gets the same address with its name as the last segment: `https://auth.your-domain.com/api/channels/messenger/webhook/{tenant}`. Without the segment the delivery belongs to the default tenant; a delivery for a tenant the channel is not enabled for gets a `403` response. One Page per webhook address: one Meta app serving the Pages of several tenants is not supported — the tenant is read from the address, not from the Page the event came to. **Access is granted by Meta, not by Veriqa.** With the default Standard Access the app receives events only for people who have a role on the app. Signing in arbitrary users needs Advanced Access, which takes Meta App Review and Business Verification — a verified legal entity. Platform limitations: * **The referral may not arrive while the Meta app is in development mode.** The user opens the `m.me` link, and Messenger delivers nothing to the webhook. This is a limit of the platform, not of the adapter. The sign-in then starts with the code shown on the sign-in page: the user sends it to the Page as a message. * **The 24-hour window.** The Page answers a user within the standard messaging window of 24 hours after that user's last message. A sign-in fits inside it: every message of the flow answers a user who has just opened the link. * **The profile needs permissions of its own.** The name and the picture are read with the Business Asset User Profile Access feature, the language with the `pages_user_locale` permission. Without them the sign-in still completes: the user is identified by the page-scoped ID, and the token carries no `given_name`, `family_name`, `picture` or `locale`. * **One event per delivery.** When Meta packs several events into one webhook delivery, Veriqa processes one of them and ignores the rest: an event that names a sign-in (an opened link, a pressed button) comes before a shared phone number, and that before any other message. * **The text of the question is truncated to 640 characters** — the limit of a message with buttons. * **The language of the messages** is the language of the user's Facebook profile when the app has the `pages_user_locale` permission, and the language of the sign-in otherwise: the webhook itself carries no language of the user. * **The phone number** is asked for with a button that Messenger shows only to a user whose profile has a phone number — see [phone number](/docs/guides/phone-number#messenger). ## WeChat The user scans a QR code with WeChat and confirms the sign-in in the chat with your Official Account. The channel talks plain HTTP to `api.weixin.qq.com` — the package carries no platform SDK. **What you need:** a verified WeChat Service Account — the platform verifies a legal entity — and from its console the AppID and the AppSecret, plus a Token you choose for the server configuration of the account. ```json appsettings.json theme={null} { "Veriqa": { "Channels": { "WeChat": { "Enabled": true, "InboundVerification": "shared_secret", "AppId": "YOUR_APP_ID", "AppSecret": "YOUR_APP_SECRET", "Token": "YOUR_SERVER_TOKEN", "ApiBaseUrl": "https://api.weixin.qq.com" } } } } ``` | Key | Notes | | - | - | | `AppId` | AppID of the Official Account | | `AppSecret` | AppSecret of the account; the access token of every outgoing call is obtained with it | | `Token` | The Token of the account's server configuration; every incoming request is checked against it | | `ApiBaseUrl` | Defaults to `https://api.weixin.qq.com`; must be an absolute HTTP or HTTPS URL | With `"Enabled": true` the first three keys are required, and so is `InboundVerification` — its value for WeChat is `shared_secret`: a deployment with an empty one does not start, and the error message names the key. `ApiBaseUrl` is a setting of the installation; the other three are the credentials of a tenant. There is no link to build here: for every sign-in Veriqa asks the platform for a temporary QR code whose scene is `auth_{transactionId}`, and the sign-in page shows that code. The user scans it with WeChat — a user who does not follow the account yet follows it first — and gets the question with two options under it; tapping one answers it, and the outcome arrives as a message of its own. The code is valid for 30 minutes, longer than any sign-in. **The webhook** is configured on the platform's side. In the server configuration of the account set the URL to `https://auth.your-domain.com/api/channels/wechat/webhook`, enter the same `Token` and choose the plaintext message mode. The platform checks the URL with a `GET` carrying `signature`, `timestamp`, `nonce` and `echostr`; Veriqa answers it automatically once the channel is enabled and the host is publicly reachable. Every request — that check and each `POST` after it — is signed with SHA-1 over the Token, the timestamp and the nonce; a request with a missing or wrong signature, or with a timestamp more than five minutes off the host's clock, is rejected. **A tenant with an Official Account of its own** gets the same address with its name as the last segment: `https://auth.your-domain.com/api/channels/wechat/webhook/{tenant}`. Without the segment the request belongs to the default tenant; a request for a tenant the channel is not enabled for gets a `403` response. The signature is checked with the Token of that tenant, and each account keeps an access token of its own. **Add your hosts to the IP allowlist of the account.** The platform accepts API calls only from the addresses listed there. A call from any other address is refused, and the QR code of the sign-in cannot be created. Platform limitations: * **The messaging window.** The account may send three messages within a minute after a scan and five within 48 hours after a message from the user. A sign-in fits inside it: the question follows the scan, the outcome follows the tapped option. A message outside the window is refused by the platform and is not sent again. * **Plaintext mode only.** A delivery in the encrypted mode is not read. * **A slow answer repeats the question.** The platform delivers an event again when it gets no answer within five seconds, and the channel does not tell that delivery from a new scan — the user may get the question twice. Either copy answers the sign-in. * **The OpenID and nothing else.** The platform gives an Official Account neither a name, nor a picture, nor the user's language: the token carries the base claims only, `name` holds the OpenID, and the messages are in the language of the sign-in. * **No phone number.** An Official Account has no way to ask for a phone number in the chat, so the channel does not provide one — see [phone number](/docs/guides/phone-number). * **Scanning is the confirmed way in.** The channel button opens the content of the same QR code. Whether a mobile browser hands that address over to the WeChat app, and what a system camera does with the code, has not been confirmed — rely on WeChat's own scanner. * **Several hosts, one account.** The access token is obtained with the platform's stable-token call, so hosts serving one account do not revoke each other's tokens. * **The name `wechat` is taken.** An [HTTP channel](/docs/guides/custom-channel-adapter) registered under that name prevents the host from starting — rename it. ## Viber The user confirms the sign-in in Viber, in a conversation with your bot. The channel talks plain HTTP to `chatapi.viber.com` — the package carries no platform SDK. **Not verified on the live platform.** The adapter has been run only against a mock of the Viber Bot API; no sign-in has been made through a real Viber bot. What this section says about the platform comes from the [Viber REST Bot API documentation](https://developers.viber.com/docs/api/rest-bot-api/) — check the channel with your own bot before you rely on it. **A bot is a commercial product of Viber.** Since February 5, 2024 Viber issues new bots on commercial terms only. Get the bot and its token from Viber before you plan a deployment. **What you need:** a Viber bot, and from Viber — its authentication token and its URI. ```json appsettings.json theme={null} { "Veriqa": { "Channels": { "Viber": { "Enabled": true, "InboundVerification": "signature", "AuthToken": "YOUR_BOT_AUTH_TOKEN", "BotUri": "yourbot", "SenderName": "Your Product", "WebhookBaseUrl": "https://auth.your-domain.com", "ApiBaseUrl": "https://chatapi.viber.com" } } } } ``` | Key | Notes | | - | - | | `AuthToken` | Authentication token of the bot. Outgoing calls are authorized with it, and every incoming delivery is checked against it | | `BotUri` | URI of the bot; the `viber://pa` link behind the QR code and the button is built from it | | `SenderName` | Name the bot's messages are signed with. Defaults to `Veriqa`; at most 28 characters — the limit of the platform. A longer name in the application settings stops the application at startup; a longer name of a tenant is not checked at startup — messages of that tenant are not sent, and an `Error`-level entry is written to the log | | `WebhookBaseUrl` | Public address of the host, scheme and host name; must be an absolute HTTPS URL | | `ApiBaseUrl` | Defaults to `https://chatapi.viber.com`; must be an absolute URL | With `"Enabled": true`, `AuthToken`, `BotUri` and `WebhookBaseUrl` are required, and so is `InboundVerification` — its value for Viber is `signature`: a deployment with an empty one does not start, and the error message names the key. `SenderName` and `ApiBaseUrl` have defaults. `WebhookBaseUrl` and `ApiBaseUrl` are settings of the installation; `AuthToken`, `BotUri` and `SenderName` are the credentials of a tenant. The sign-in link is `viber://pa?chatURI={BotUri}&context=auth_{transactionId}`. It opens the conversation with your bot, Viber hands the `context` value to Veriqa, and the user gets the question with the Confirm and Decline buttons; the outcome arrives as a message of its own. **The webhook** of the installation's own bot is registered by Veriqa. Once the host accepts requests, it calls `set_webhook` with the address `{WebhookBaseUrl}/api/channels/viber/webhook` and subscribes to the `conversation_started` event; the user's messages are delivered to a webhook without a subscription. Viber answers that call only after it has sent a check request to the address and got `200` back, so the host has to be publicly reachable over HTTPS by then. A refused registration is written to the log as an error and does not stop the host: the other channels keep working, and the registration is not retried until the next start. On shutdown the host removes the webhook. Every delivery carries the `X-Viber-Content-Signature` header — HMAC-SHA256 of the request body keyed with the bot token, in hexadecimal form. A delivery with a missing or wrong signature is rejected. **A tenant with a bot of its own** gets the same address with its name as the last segment: `https://auth.your-domain.com/api/channels/viber/webhook/{tenant}`. Veriqa registers only the webhook of the installation's own bot. The webhook of a tenant's bot is registered by whoever sets the tenant up: a `set_webhook` call with the token of that bot, the address with the `{tenant}` segment and the `conversation_started` event type ([Viber: setting a webhook](https://developers.viber.com/docs/api/rest-bot-api/)). Without the segment the delivery belongs to the default tenant; a delivery for a tenant the channel is not enabled for gets a `403` response. The signature is checked with the token that resolves for that tenant — its own, or the installation's when the tenant has none. Platform limitations: * **One message before the user answers.** After the conversation is opened the bot may send one message; the next one goes through only after the user has written to the bot. A sign-in fits: the question is that one message, and a tap on Confirm or Decline is the user's answer. * **The tap shows in the chat.** A button of a Viber keyboard sends its value as a message from the user, so the conversation shows a line starting with `vq_confirm_` or `vq_decline_`. * **A sent message cannot be changed.** The outcome always arrives as a new message; it also removes the question's buttons. * **The profile is what the event carries**: the name, the picture and the language of the user. The messages are in that language; without it, in the language of the sign-in. * **The phone number** is asked for with the `share-phone` button, and Viber does not prove that the number belongs to the user — see [phone number](/docs/guides/phone-number#viber). * **No polling.** The Bot API delivers events to a webhook only, so the channel cannot run on a host that is not reachable from the internet. * **The name `viber` is taken.** An [HTTP channel](/docs/guides/custom-channel-adapter) registered under that name prevents the host from starting — rename it. ## Email Email is the only channel with two independent directions, and they can run together: * **Pull (magic link)** — Veriqa emails a link, the user clicks it. On by default (`PullEnabled`). * **Push — the one-tap email** — the user sends a pre-filled email to the inbound address (one tap from a button or a QR code) and Veriqa reads it. Off by default (`PushEnabled`); it needs inbound delivery and a sender verification policy. `Push` is the value in configuration; "one-tap email" is what the mode is called for users. ```json appsettings.json theme={null} { "Veriqa": { "Channels": { "Email": { "Enabled": true, "InboundVerification": "shared_secret", "PublicBaseUrl": "https://auth.your-domain.com", "PreferredMode": "Pull", "PullEnabled": true, "PushEnabled": false, "TokenTtl": "00:05:00", "MailResendInterval": "00:00:30", "Outbound": { "Provider": "Smtp", "FromAddress": "login@your-domain.com", "FromName": "Veriqa", "Smtp": { "Host": "smtp.your-provider.com", "Port": 587, "UseSsl": false, "Username": "…", "Password": "…" } } } } } } ``` `PublicBaseUrl` is **required** whenever the channel is enabled — magic links and QR codes are built from it. `TokenTtl` is the lifetime of a magic link and of the tokens of the claims completion; the token of the Push sign-in email stays valid until its transaction expires instead. `MailResendInterval` is the shortest time between two emails of one sign-in — see [Sending an email again](#sending-an-email-again). `Port: 587` with `UseSsl: false` means STARTTLS; `UseSsl: true` means implicit TLS on port 465. `RequireStartTls` (default `true`) applies when `UseSsl` is `false` and `Username` and `Password` are set: if the server does not offer STARTTLS, the connection is refused, so the password is never sent in clear text. Set it to `false` only for an internal relay you deliberately reach without TLS — STARTTLS is then used only when the server offers it. An anonymous relay (no credentials) is not affected. `Username` and `Password` are set **together only**. Leaving both empty is a legitimate configuration: that is a relay without authentication, the usual arrangement for an internal SMTP server inside a company network. Setting **exactly one** of them, however, is refused at startup — no authentication would be attempted at all, so the value you configured would silently never be used. If Veriqa does not start with a message about `Username` and `Password`, either remove the leftover value or set both. ### Which mode to choose The magic link is on by default and needs nothing beyond outbound mail, so it is the cheaper start. The one-tap email costs a way to receive mail and a sender policy — and it no longer requires an inbound mail provider: Veriqa can read an ordinary mailbox itself, see [One-tap email over IMAP](#one-tap-email-over-imap). It buys four things the magic link cannot: * **Nothing has to reach the user for the sign-in to work.** The email travels the other way, so the sign-in no longer depends on your outbound mail arriving: no spam folder, no greylisting, no domain reputation. With `PullEnabled: false` the channel needs no SMTP at all — the options validator asks for outbound settings only when Pull is on. * **There is no link for a mail gateway to burn.** Corporate mail protection opens links in incoming messages to check them, and a one-time magic link can be used up before the person clicks it. An email the user sends carries no such link. * **It proves control of the mailbox, not just that the message arrived.** Following a link shows that the message reached someone — a forwarding rule or a shared mailbox included. Sending the email shows that the sender controls the box, and the sender is checked against `VerificationPolicy` (`DmarcAlignedPass` by default). * **It is the same move the messengers use.** The user sends a ready-made message in one tap — [the secret message](/docs/guides/supported-channels#secret-message). Email is that pattern with an email instead of a chat message, so the sign-in page teaches one habit, not two. The modes are independent and can run together: `PreferredMode` decides which one the sign-in page offers first, and with Push first the sign-in page keeps the address field for a magic link as a fallback while Pull is on. ### Push mode — the one-tap email Push additionally needs the `Inbound` block. Incoming mail reaches Veriqa in one of two ways: an inbound mail provider posts it to a webhook (below), or Veriqa reads a mailbox itself — [One-tap email over IMAP](#one-tap-email-over-imap). ```json appsettings.json theme={null} { "Inbound": { "Provider": "Webhook", "InboundAddress": "login@your-domain.com", "UsePlusAddressing": true, "VerificationPolicy": "DmarcAlignedPass", "WebhookSecretToken": "YOUR_SECRET" } } ``` Incoming mail is posted to `POST https://auth.your-domain.com/api/channels/email/inbound`, authenticated by the `X-Email-Webhook-Secret` header. The endpoint accepts one flat JSON object in Veriqa's own format — not the native payload of a mail service: ```json theme={null} { "messageId": "", "from": "user@example.com", "envelopeSender": "user@example.com", "to": "login+TOKEN@your-domain.com", "subject": "Sign in", "body": "…", "receivedAt": "2026-01-01T12:00:00Z", "spf": "pass", "dkim": "pass", "dmarc": "pass" } ``` An inbound service that posts in its own format (Mailgun, Postmark, SendGrid Inbound Parse, …) therefore needs a small forwarder in front of the endpoint — a Cloudflare Email Worker or a function of your own — that converts each message into this object. To handle a native format inside the host instead, set `Provider` to `Custom` and register your own `IEmailInboundProcessor`. In a multi-tenant installation the same route also accepts an optional tenant segment — `POST https://auth.your-domain.com/api/channels/email/inbound/{tenant}` — and each tenant configures its own inbound provider with its own segment. Mail arriving there is then handled with THAT tenant's `Inbound` settings and webhook secret. Without the segment the route uses the installation-wide settings. `UsePlusAddressing` embeds the correlation token into the reply address (`login+{token}@…`), which is how an inbound message is tied back to its sign-in transaction. `VerificationPolicy` decides how much the sender is trusted — an email `From` is trivially forged, so this is a security control, not a formality: | Value | Behavior | | - | - | | `DmarcAlignedPass` | Default. Requires a DMARC-aligned pass — the recommended setting | | `SpfOrDkimPass` | Weaker: SPF **or** DKIM passing is enough | | `AllowListOnly` | Only domains listed in `AllowedDomains` | Do not disable sender verification in production. Without it, anyone who can forge a `From` header can complete someone else's sign-in. ### What the user sees after the tap Without hop mode the QR code and the **"Send confirmation email"** button of the sign-in page carry the email itself — a `mailto:` link with the recipient, the subject and the body filled in. No page of Veriqa opens in between: the phone that scans the code, or the computer on which the button is pressed, opens the mail client with the email ready to send. * When its button is pressed, the sign-in page shows the line "The confirmation email is open in your mail app. Send it to continue." by itself. When the user comes back to the page from the mail app while that line is still on it, the line becomes "If you have already sent the email, wait for the confirmation. It usually takes a few seconds." A reload forgets the tap. * An email opened from the QR code reports nothing to the sign-in page until the email arrives; from then on the page shows that the email was received and how the sign-in ended. * A sender that passed verification is not yet a sign-in: when the sign-in has already been taken up by the account of another channel, the email does not sign in, and instead of "Sender verified" the page says "Sign-in has already started in another channel." ("Confirmation has already started in another channel." on a confirmation page). * Where the sign-in window asks — the default for Email, see "The emailed-link page" below — the email does not sign the user in by itself: it proves the mailbox, and the sign-in page moves on to the question "Do you confirm the sign-in?". **Yes** signs the user in; **No** declines the sign-in, and the application gets `access_denied`. With `LoginConfirmation.Mode` set to `None` the email completes the sign-in itself. `Inbound:TokenInLocalPart` chooses the form of the recipient address, not the target: | `TokenInLocalPart` | Recipient of the email | Where the sign-in token is carried | | - | - | - | | `false` (default) | `InboundAddress` as it is, or `login+{token}@your-domain.com` with `UsePlusAddressing` | the subject and the body of the email, and the plus segment when it is on | | `true` | `{token}@your-domain.com` — needs a catch-all mailbox on the domain of `InboundAddress` | the address alone; the subject and the body stay short | When the email cannot be built — an `InboundAddress` it cannot be addressed to (unset outside Production, or without a domain while `TokenInLocalPart` is on), or a failure of the token store — the reason is written to the log and the channel keeps what is left: with `PullEnabled` the sign-in page shows the address field without the mail-client button and without the QR code; without it the Email channel is not offered. An email too long for a QR code — a long prefilled email text — leaves the channel with its button and without the QR code. ### Email under hop mode In [hop mode](/docs/reference/configuration#veriqahopmode) the QR code of the Email channel carries the hop link instead of the email, and the phone is still taken to the email itself. What it gets depends on one thing — whether sign-in with an emailed link (`PullEnabled`) is on: | Emailed link (`PullEnabled`) | `RedirectStyle` | What the phone gets | | - | - | - | | on | `Interstitial` or `Direct` | the page with the email button and the address field | | off | `Interstitial` | the launch page every channel gets: it opens the email by itself and keeps a button in case it did not open | | off | `Direct` | no page — the hop link opens the email at once | With the emailed link on, the page never opens the mail client by itself: it holds an address field, and a client that opened on its own would take the user away from it. After the tap the page says "The confirmation email is open in your mail app. Send it to continue.", once the user is back from the mail app, it asks them to wait for the confirmation, and when the sign-in ends it shows the outcome. With the emailed link off, Email behaves like any other channel under hop mode: the launch page opens the email by itself, and once the user taps its button it says the same line, then shows how the sign-in ended. A user who comes back to the launch page from the mail app — or returns to it with **Back** — sees "If you have already sent the email, wait for the confirmation. It usually takes a few seconds." instead. Where the sign-in window asks the question — the default for Email, see [The page of the emailed link](#the-page-of-the-emailed-link) — the mail ties the mailbox to the sign-in, and the question waits on the sign-in window. Both phone pages then say "Return to the sign-in window and confirm the sign-in there.": the page with the mail button takes away the address field and the mail button and keeps its countdown; the launch page takes away its instruction, its button and the spare code and goes on to show how the sign-in ended. The mail statuses that follow no longer replace the line. The phone pages of every other channel do the same when `LoginConfirmation.Mode` is `OnWebPage`. Under `OnWebPage` the chat does not stay silent either: once the sender is linked to a sign-in, a channel that sends messages to the chat replies once with "Sign-in started — confirm it on the sign-in page", in the recipient's language, so the user knows to check the sign-in window. It is sent once per sign-in — starting again from the same link sends nothing more — and only for a sign-in, not for a confirmation. Email sends no such email. The text is the message kind `web-confirmation-notice`, which you can reword like any other message, per channel as well — see [Veriqa:MessageTemplates](/docs/reference/configuration#veriqamessagetemplates). The outcome receipt follows as before, and a "No" on the page sends nothing further to the chat. The sign-in page on the computer follows the phone: the scan of the Email QR code shows "QR code scanned. Continue on your phone to confirm by email.", and opening the email — by the tap, by the automatic start of the launch page, or by the redirect itself where there is no page — shows "The confirmation email is open in your mail app. Send it to continue." ### The emailed-link page The link in a sign-in email (`PullEnabled`) opens a page of Veriqa, and what that page does depends on where the sign-in is confirmed — the `LoginConfirmation.Mode` key of the [configuration reference](/docs/reference/configuration): * **The sign-in window asks** — the default for Email. The page asks nothing. Titled "Sign-in request", it names the application — "A sign-in to `{app}` was requested. If it was not you, close this page." — and offers **Continue signing in** and **Cancel**. "Continue signing in" passes the sign-in on to the sign-in window: it uses up the link and ties the mailbox to the sign-in without completing it, and the page turns into "Return to the sign-in window and confirm the sign-in there." with a **Close** button. The one question of the sign-in — confirm or decline — is asked once, on the sign-in window where the sign-in started: **Yes** signs the user in, and profile details the application still lacks are collected right there; **No** declines the sign-in, and the application gets `access_denied`. The same link opened again says it is invalid or has expired. * **The sign-in window does not ask** (`None`). The link completes the sign-in when it is opened, and the page shows how the sign-in ended. **The browser that took part in the sign-in is not asked to press the button.** When the sign-in holds the fingerprint and the IP address of the phone that scanned its hop QR code — [hop mode](/docs/reference/configuration#veriqahopmode) with `HopMode.CollectFingerprint` on, and `HopMode.CollectIpAddress` on (the default) — the page computes the fingerprint of the browser it is open in and continues by itself when both match: the user sees "Return to the sign-in window…" without a tap. Any mismatch, a browser that cannot compute the fingerprint, or a sign-in without those values leaves the button on the page. The fingerprint sent by the page is compared and dropped: it is neither stored nor written to the log. "Cancel" and "Close" only close the tab and change nothing; when the browser keeps a tab it did not open by script, the page says "You can close this tab." instead. Opening the page does not use up the link: a mail gateway that follows it passes nothing on — only the POST of the button does. The link of a [confirmation](/docs/reference/confirmation-api) keeps its own page with one "Continue" button. ### Sending an email again After a link has been sent, the address field of the sign-in page stays editable and the button turns into **"Send again"**. It stays disabled for `MailResendInterval` — 30 seconds by default — and then comes back by itself; a request that arrives sooner is refused with `429` and sends nothing. ```json appsettings.json theme={null} { "Veriqa": { "Channels": { "Email": { "MailResendInterval": "00:00:30" } } } } ``` * The interval is counted **per sign-in, not per address**: correcting the address and pressing the button within the interval is refused the same way. * The sign-in email and the emails of the [claims completion](/docs/concepts/claim-enrichment) are counted separately, so a sign-in by an emailed link does not delay the first completion email. * `00:00:00` switches the interval off; a negative value prevents startup. The setting is node-wide. * The interval is not the request limit and not the email limits of the claims completion (`ClaimCompletionEmailLimits`): all three apply independently. * An email that failed to go out still starts the interval — the next attempt waits it out. The response to `POST /auth/email/start` carries `retryAfterSeconds` — how long to wait before the next email — both on success and in the `429` refusal, which also sets the `Retry-After` header. A sign-in page of your own can disable its button for that long. ### One-tap email over IMAP With `Inbound:Provider` set to `ImapPolling` Veriqa reads the mailbox itself: no inbound provider, no webhook secret and no public address of your server are needed — a mailbox and its password are enough. A background service signs in to the mailbox over IMAP, takes the unread emails and hands each of them to the same processing a webhook delivery gets, so the sender check and the result of the sign-in are identical. ```json appsettings.json theme={null} { "InboundVerification": "outbound_fetch", "PushEnabled": true, "Inbound": { "Provider": "ImapPolling", "InboundAddress": "login@your-domain.com", "UsePlusAddressing": true, "VerificationPolicy": "DmarcAlignedPass", "Imap": { "Host": "imap.your-provider.com", "Port": 993, "Username": "login@your-domain.com", "AuthMode": "Password", "Password": "…", "Folder": "INBOX", "PollIntervalSeconds": 10, "AuthservId": "mx.your-provider.com" } } } ``` The keys of `Veriqa:Channels:Email:Inbound:Imap`: | Key | Required | Default | Purpose | | - | - | - | - | | `Host` | yes | — | Name of the IMAP server | | `Port` | no | `993` | Port, 1…65535. `993` means implicit TLS, any other port means STARTTLS | | `Username` | yes | — | User name of the mailbox | | `AuthMode` | no | `Password` | How Veriqa signs in to the mailbox: `Password` or `OAuth2` | | `Password` | with `AuthMode: Password` | — | Password of the mailbox or its app password. A secret | | `Folder` | no | `INBOX` | The folder Veriqa reads | | `PollIntervalSeconds` | no | `10` | Upper bound of the pause between two visits to the mailbox, 1…3600 | | `AuthservId` | with `DmarcAlignedPass` or `SpfOrDkimPass` | — | The `authserv-id` of the mail server of the mailbox — see below | `InboundAddress` is required with this provider in every environment, not only in Production: it is the address the user's email is sent to, so it has to be the address of this mailbox. With `UsePlusAddressing` the email goes to `login+{token}@…` — check that your mail provider delivers plus-addressed mail to the base mailbox. **How it behaves.** * An email reaches the sign-in — completes it, or ties the mailbox to it where the sign-in window asks — no later than `PollIntervalSeconds` plus the time of one visit to the mailbox after it has arrived. * Only unread emails that arrived no earlier than `TokenTtl` ago are taken: an older email is neither read nor marked. An email that was handled — accepted or rejected — is marked as read. Nothing is ever deleted. * The connection is TLS only and the server certificate is always validated; there is no switch that turns the validation off. * A failure of the mailbox or of the authorization server — network, wrong password, a refused token — does not stop the application or the other channels. The error is logged without secrets and the visit is retried after 1 second, then 2, 4 and so on up to 60 seconds; the first successful visit resets the pause. * The webhook route `/api/channels/email/inbound` answers `404` with this provider. * In a multi-tenant installation every tenant with this provider is read by its own loop with its own mailbox settings. **Where `AuthservId` comes from.** SPF, DKIM and DMARC are checked by the mail server of the mailbox, which writes the verdicts into the `Authentication-Results` header of the email ([RFC 8601](https://www.rfc-editor.org/rfc/rfc8601)). A sender can put a forged header of the same name into an email, so Veriqa trusts only the header whose first word — the `authserv-id` — equals `AuthservId`, and of several such headers the topmost one. When an email carries no header with this identifier, the verdicts are empty and the policy rejects it. `AllowListOnly` does not read the header, so it does not need the key. To learn the value, open the source of any email this mailbox has received ("Show original" or a similar command in your mail client) and look at the topmost `Authentication-Results` header: * `Authentication-Results: mx.your-provider.com; spf=pass …; dkim=pass …; dmarc=pass …` — the value opens with a host name followed by `;`. That name is the `AuthservId`. * `Authentication-Results: spf=pass …; dkim=pass …; dmarc=pass …` — the value opens with a verdict (a word with `=`), so the server states no `authserv-id`. There is nothing to put into `AuthservId`: do not copy `spf=pass` there. Compare the sources of several emails: `AuthservId` holds one value. A mailbox whose identifier is missing or differs from email to email does not fit this mode — connect it through the webhook or use a mailbox of another provider. **A mailbox whose server writes the header without an `authserv-id` cannot be used with `DmarcAlignedPass` or `SpfOrDkimPass`.** Veriqa has no way to tell the header of such a server from one the sender wrote, so a header without an identifier is never trusted. Do not fill `AuthservId` with a value the server of the mailbox does not write itself: an email with no header carrying that value is rejected and marked as read, so honest sign-ins do not complete, while a sender who writes a header with that value into the email gets it trusted — the sender check then passes on verdicts the sender wrote. Microsoft documents this form of the header for Microsoft 365 — see [Authentication-results message header](https://learn.microsoft.com/defender-office-365/message-headers-eop-mdo#authentication-results-message-header); check an email in your own mailbox as described above. For such a mailbox either receive the emails through the webhook of a mail provider (`Inbound:Provider: Webhook`), or read a mailbox of another provider. `AllowListOnly` does start with such a mailbox, but it checks only the domain of `From`, which a sender can forge — it does not replace the SPF, DKIM and DMARC check. **Dedicate the mailbox — or at least the folder — to Veriqa.** Every unread email in `Folder` that is recent enough to carry a live token gets marked as read, whoever sent it. Do not point the service at a mailbox people read. Limitations: * `VerificationPolicy: ProviderVerified` cannot be combined with `ImapPolling` — a mailbox supplies no provider-verified sender; the host does not start with this pair. * A mismatch between `ImapPolling` and an `InboundVerification` other than `outbound_fetch` is a `Warning` at startup, as with a messenger in `Polling` mode. #### Mailbox sign-in with OAuth Some mailboxes do not accept a password at all — Microsoft 365, and Google Workspace organizations that turned password sign-in off. OAuth changes only how Veriqa signs in to the mailbox: the sender of an email is still checked by `VerificationPolicy`, so first make sure the mailbox passes the `AuthservId` check above. For such mailboxes set `AuthMode` to `OAuth2` and fill `Veriqa:Channels:Email:Inbound:Imap:OAuth`. The section holds not an access token — that one is valid for about an hour — but the long-lived data it is obtained with: Veriqa requests the token from `TokenEndpoint` itself, keeps it in memory and requests a new one when it expires. | Key | Required | Purpose | | - | - | - | | `TokenEndpoint` | yes | Token endpoint of the authorization server. An absolute `https` URL | | `ClientId` | yes | Identifier of the application you registered with the authorization server | | `ClientSecret` | when `RefreshToken` is empty | Secret of the application. A secret | | `RefreshToken` | no | Set — the token is obtained with the refresh token; empty — with the application's credentials. A secret | | `Scope` | when `RefreshToken` is empty | Requested permissions. With a refresh token it is sent only when set | `Password` is ignored in this mode, and the `OAuth` section is ignored with `AuthMode: Password`. The mailbox is signed in to as `Username` with the `OAUTHBEARER` mechanism when the server offers it, with `XOAUTH2` otherwise. Which of the two standard grants ([RFC 6749](https://www.rfc-editor.org/rfc/rfc6749)) is used follows from `RefreshToken`: * **With a refresh token** — the mailbox of a particular user, who gave the consent once. Choose it when the mailbox is an ordinary account and the provider issues refresh tokens for it. * **With the application's credentials** (`client_credentials`) — the application reads the mailbox without any user. Choose it when the administrator of the organization can grant the application access to the mailbox; there is no refresh token to renew. ```json With a refresh token theme={null} { "Imap": { "Host": "imap.your-provider.com", "Username": "login@your-domain.com", "AuthMode": "OAuth2", "AuthservId": "mx.your-provider.com", "OAuth": { "TokenEndpoint": "https://auth.your-provider.com/oauth2/token", "ClientId": "YOUR_CLIENT_ID", "ClientSecret": "…", "RefreshToken": "…" } } } ``` ```json With the application's credentials theme={null} { "Imap": { "Host": "imap.your-provider.com", "Username": "login@your-domain.com", "AuthMode": "OAuth2", "AuthservId": "mx.your-provider.com", "OAuth": { "TokenEndpoint": "https://auth.your-provider.com/oauth2/token", "ClientId": "YOUR_CLIENT_ID", "ClientSecret": "…", "Scope": "SCOPE_FROM_YOUR_PROVIDER_DOCS" } } } ``` The addresses and the scopes above are placeholders. Registration of the application, the permissions it needs and the actual values are described by the mail provider — take them from its documentation, they change: * **Microsoft 365** — [Authenticate an IMAP, POP or SMTP connection using OAuth](https://learn.microsoft.com/en-us/exchange/client-developer/legacy-protocols/how-to-authenticate-an-imap-pop-smtp-application-by-using-oauth): the registration of the application, the scope for each of the two grants and, for the credentials of the application, the steps an administrator performs to let it into a mailbox. The token endpoint is described in [OAuth 2.0 client credentials flow](https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-client-creds-grant-flow) and [OAuth 2.0 authorization code flow](https://learn.microsoft.com/en-us/entra/identity-platform/v2-oauth2-auth-code-flow). * **Google** — [OAuth 2.0 mechanism](https://developers.google.com/workspace/gmail/imap/xoauth2-protocol) names the scope of IMAP access, and [Using OAuth 2.0 to Access Google APIs](https://developers.google.com/identity/protocols/oauth2) describes the registration of the application, the token endpoint and how a refresh token is obtained. Only the refresh token grant applies here: see the limitations below. Limitations of the OAuth sign-in: * **The consent is given outside Veriqa.** Veriqa has no consent screen and no redirect address for this: the administrator obtains the refresh token following the mail provider's instructions and puts it into the secrets. * **A renewed refresh token is not written back.** An authorization server may return a new refresh token together with an access token; Veriqa uses it until the process stops and never writes it to configuration. With a provider whose refresh tokens expire, the administrator replaces the value in the secrets. * **A Google service account is not supported** — sign-in with a signed JWT ([RFC 7523](https://www.rfc-editor.org/rfc/rfc7523)) is not implemented. ### Providers The shipping providers are **`Smtp`** for outbound and **`Webhook`** and **`ImapPolling`** for inbound. Both settings also accept **`Custom`**, in which case you register your own `IEmailOutboundSender` / `IEmailInboundProcessor` in DI before calling `AddEmail()`. Any other value fails fast at startup rather than silently doing nothing. A custom `IEmailOutboundSender` sends the sign-in email only, unless it also answers `SupportsClaimCompletionEmail` with `true` and implements `SendClaimCompletionEmailAsync`: those two members send the code and link emails that confirm an email address during the claims completion. Without them the "Confirm via email" way of obtaining the email — the one `Code` and `MagicLink` stand for — is unavailable, and the web step offers only the ways that remain. **Multi-instance caveat.** Email magic-link tokens and Push correlation tokens are held **in memory** by the shipping stores. Across several instances a link issued by one instance is unknown to the others. Run Email on a single instance, route by sticky sessions, or register distributed implementations of `IEmailActionTokenStore` and `IEmailPushCorrelationStore`. The other channels are unaffected. Your own `IEmailPushCorrelationStore` implementation must override `StoreOrReuseAsync` — the idempotent registration of a token ("the transaction already has a live token — return it and write nothing"). The interface's default implementation simply stores the supplied token, so without an override Push mode issues a **new** correlation token on every render of the sign-in page, and all of them stay alive until the TTL expires or one is consumed. The capture of the "transaction → live token" index must be atomic (compare-and-swap / `SET NX` / transaction), and the TTL of a reused token must not be extended. ### Your own mail body The sign-in email is an ordinary **message** of the text mechanism: both the subject and the whole HTML document of the body live as the value of the `Veriqa:MessageTemplates:sign-in-mail` key (how the key is built — in the [Configuration reference](/docs/reference/configuration#veriqamessagetemplates)). So the email is rewritten **by a setting, with no code involved**: set your own ladder of variants at the level you need. ```json appsettings.json theme={null} { "Veriqa": { "MessageTemplates": { "sign-in-mail": { "Templates": [ { "Subject": "Sign in to {app}", "Html": "

Sign in to {app}

{button}

{qr_instruction}

\"{qr_alt}\"", "Plain": "{text_intro}\n{open_link_hint}\n{link}\n\nRequest from {browser} on {os}, {region}" }, { "Subject": "Sign in to {app}", "Html": "

Sign in to {app}

{button}

", "Plain": "{text_intro}\n{open_link_hint}\n{link}" } ] } } } } ``` What matters here: * **the markup inside a variant is not escaped by the product** — it is written by whoever owns the configuration. What is escaped are the **slot values**: they come from outside, an application over the API included; * **the visible phrases stay localization keys.** `{button}`, `{qr_instruction}`, `{qr_alt}` and their kin are localized-text slots: the message contract states which key a slot looks at, and the translation for the recipient's language is what gets substituted. The markup is therefore one text for every language, and there is nothing to translate as copies. The shipped email has no phrase outside such slots — `{heading}`, `{text_intro}`, `{request_from_label}`, `{button_hint}`, `{valid_until_label}` and the rest; the two-variant example above writes its heading and the "Request from" line into the markup, and those stay in the language you wrote them in; * **what Veriqa computes is never shortened; what comes from outside is.** `{link}`, `{valid_until}` and the other values the product computes itself are substituted in full. The application name, the initiator details and a translated phrase are held to 128 characters — keep one phrase per slot short, and split a long sentence into several slots; * **the form of the QR is decided by the text, not by a setting.** A variant naming `{qr_cid}` gets the PNG attachment; a variant naming `{qr_url}` instead (the link to the QR endpoint, if your composer supplies it) gets the image by URL and carries no attachment; a variant naming neither is the email without a QR. An attachment nothing points at never happens; * **the email is one, and a step of the ladder has two editions.** A step is either a string (a text that suits any sink) or an object `{ "Subject": …, "Html": …, "Plain": … }`. Each edition is optional, but a step must specify at least one: a step specifying none fails startup, naming the message kind and the number of the step. A step specifying only `Html` does not serve the plain-text part — the selection simply moves on down the ladder, so **the two parts of one email may come from different steps**. The subject is then taken from the step chosen for `Html`; * **the last step of the ladder must rely on guaranteed slots only** (`app`, `valid_until`, `link`, `lang`). The initiator details and the QR are not guaranteed: a transaction without them simply reaches the step that names none of them — no empty paragraph in the email, and no literal `{browser}`. This is counted **per edition**: the selection runs among the steps specifying the edition asked for, so a ladder has two "last" steps — the last one with `Html` and the last one with `Plain` — and the rule holds for both. A ladder whose last `Html` step names `{browser}`, with only the `Plain` one made slot-free, fails startup; * **an HTML-only email (or a text-only one) is a legitimate declaration.** When no step specifies `Plain`, the email goes out without a text part: no error in the log, no substitution of one edition for the other. Which is exactly why you should make sure a one-part email is your choice and not a forgotten edition: in the data the two are indistinguishable. **The ladder is a safety net under the data, not a substitute for settings.** It exists because the initiator details (`{browser}`, `{os}`, `{region}`) and the QR may be missing at runtime — not because wordings may be left unwritten. A declaration **replaces the whole ladder**: specify a single step just to "reword one line" and you silently lose the entire ladder the core ships, and with it every degraded variant. And the last step is no technical stub: its text is the one a user reads most often, in exactly the conditions where the context is unavailable. The core says both of these out loud **once at startup**, as a `Warning` record: when a configured ladder is shorter than the one shipped at the same address (the message names the kind, the address and both step counts), and when no step specifies `Plain`. Startup is not blocked — a short ladder and a one-part email remain legitimate declarations. **What is checked is what startup can see:** the deployment's section and the shipped file, that is, the `Core` level. Values set by a tenant, an application or a `ui_config` record cannot be enumerated at startup — their absence from the warnings does not mean all is well there. If the email has to be built **in code** — pulling the markup from your CMS, supplying slot values of your own, or deciding the subject yourself — one seam is left: the public `IEmailMessageComposer`. It receives the data of the send and returns the finished body of the email (`EmailBodyContent`: the subject, the HTML part, the plain-text part and the QR-attachment flag), while the delivery provider deals with transport only. ```csharp Program.cs theme={null} builder.Services.AddSingleton(); ``` The registration follows the general DI substitution rule: an implementation of yours registered before `AddVeriqa*` is already in the container and the shipped one is not added; after the call — `RemoveAll` + `Add`. The markup of the email is set by the text of a template variant in the configuration above (its example opens this section); for full control, use `IEmailMessageComposer`. A composer of your own takes over the **whole** body of the email: the degradation ladder, the localization of the visible phrases through Natural Keys and the QR-attachment decision are then yours to implement. ### Address normalization The `Normalization` section defines which form of an address Veriqa treats as the user identity: ```json appsettings.json theme={null} { "Veriqa": { "Channels": { "Email": { "Normalization": { "LowercaseDomain": true, "LowercaseLocalPart": true, "ProviderSpecificCanonicalization": false } } } } } ``` `ProviderSpecificCanonicalization` enables provider-specific canonicalization. One family is supported — Gmail (`gmail.com` and `googlemail.com`): dots in the local part are dropped, the `+` tag is cut off, and `googlemail.com` is mapped to `gmail.com`. So `u.ser+shop@googlemail.com` and `user@gmail.com` become the same user. Domains outside that list are never canonicalized — with many providers the `+` is part of the address, and a blanket rule would merge different people. **Turning this on in a live deployment changes identity.** Users who signed in before will get a different `sub`: addresses that used to be distinct are merged into one. The core performs no migration — this is an operational decision, which is exactly why the setting is off by default. Decide it before your users' first sign-in, not after. ## Letting the client pick a channel A client can restrict a given sign-in to specific channels through the standard OIDC `acr_values` parameter, in `channel:{type}` form: ``` GET /connect/authorize ?client_id=my-app &response_type=code &scope=openid profile &acr_values=channel:telegram ``` List several (`channel:telegram channel:whatsapp`) to narrow the choice without fixing it. Without the parameter, every enabled channel is offered. ## Checklist `AddXxx()` is called **and** the section has `"Enabled": true`. Every channel section sets `InboundVerification`, and the value matches the mode the channel actually runs in. `https://…/api/channels/…` is publicly reachable over HTTPS and not blocked by your proxy. No token or password sits in a committed configuration file. A real confirmation from a phone completes the flow and issues tokens. ## Next steps Every section, key and default in one place. Sign-in page design, localization, extension points. What to check before shipping. How adapters fit the rest of the connector. # Client compatibility Source: https://veriqa.app/docs/guides/client-compatibility Compatibility quirks: named relaxations of the request form, enabled for one client. Some OIDC clients send requests in a shape that departs from the RFC. Veriqa is strict and rejects them — the right default, except when you cannot fix the client: someone else's plugin, an off-the-shelf CMS, a vendor build. A **compatibility quirk** is a named relaxation of the request form, enabled **for a single client**. Every quirk is narrow, off by default, and ships with a removal condition stated up front: once the upstream defect is fixed, the key is retired along with its handler. A quirk is a behavior branch, not a setting. Enable one only when a specific client genuinely cannot send a conformant request, and retire it as soon as the removal condition is met. Veriqa reports every enabled quirk with a startup warning in the log. ## Why per client, not globally A relaxation enabled globally weakens the check for **every** application — including those that send conformant requests, and those that will appear in the configuration tomorrow. Strictness stops being a property of the deployment and becomes a property of its weakest participant. So a quirk is addressed by `ClientId`: the client with the broken plugin gets the relaxation, every other client stays strict. That also keeps the relaxation visible — the configuration says **which** application is allowed **what** beyond the RFC. ## Enabling Enabling takes two steps: listing the quirk on the client is not enough, the deployment gate has to be open as well. While the gate is closed, the client's quirk set is intersected with the empty core-level set and no relaxation applies. ```json appsettings.json theme={null} { "Veriqa": { "OpenIddict": { "CompatibilityQuirksAllowApplicationOverride": true, "Clients": [ { "ClientId": "my-wordpress-site", "DisplayName": "My WordPress Site", "AllowedRedirectUris": [ "https://example.com/oidc/callback" ], "AllowedScopes": [ "openid", "profile", "email" ], "CompatibilityQuirks": [ "drop-scope-on-code-exchange" ] } ] } } } ``` | Key | Level | Default | Meaning | | - | - | - | - | | `Veriqa:OpenIddict:CompatibilityQuirksAllowApplicationOverride` | deployment | `false` | The gate: while it is closed no quirk takes effect, whatever the clients list | | `Veriqa:OpenIddict:Clients[].CompatibilityQuirks` | client | unset | Quirk keys from the closed registry | | `Veriqa:OpenIddict:Clients[].CompatibilityProfile` | client | unset | A name from the closed registry: expands into quirk keys and is unioned with `CompatibilityQuirks` | The two steps exist so that a single client's configuration cannot weaken the check on its own: the overall ceiling belongs to whoever owns the deployment, the specific application to its client entry. ### A profile instead of a list of keys Rather than writing the keys out one by one, you can name a profile — the engine and plugin you actually run. A profile expands into the very same keys and passes the same validation, the same gate and the same ceiling: it is a shorthand, not a second mechanism. ```json appsettings.json theme={null} { "ClientId": "my-wordpress-site", "CompatibilityProfile": "wordpress-openid-connect-generic" } ``` | Profile | What it enables | | - | - | | `wordpress-openid-connect-generic` | `drop-scope-on-code-exchange` | | `moodle-auth-oidc` | `drop-resource-parameter` | A profile and an explicit list can be combined — the keys are unioned. An unknown profile name fails the start with the list of allowed names, exactly as an unknown quirk key does: a typo cannot quietly leave the relaxation switched off. A profile may carry **relaxations of the request form and nothing else**. Nothing that changes claims can be enabled by one: switching on compatibility with a broken client must not also change what the token says about who signed in. ## Disabling Remove the key from that client's `CompatibilityQuirks` — the relaxation stops applying on the next start. To drop every quirk in the deployment at once without touching client entries, close the gate: `CompatibilityQuirksAllowApplicationOverride: false`. Nothing needs disabling to begin with: **the default is off for everyone**. A fresh deployment applies no quirk until you open the gate and list a key explicitly. ## What Veriqa reports at startup * **A quirk is in effect** — a warning listing "client → enabled quirks". This noise is intended: an enabled relaxation must not be silent. * **A quirk is listed but not in effect** — a warning with the reason. If the gate is closed it says so, and opening the gate is the fix. If the gate is open and the set is still empty, the gate is not the cause: the client entry never reached the resolver, most often because a second entry with the same `ClientId` shadows it. * **An unknown key** — the start does **not** continue: the configuration picks a key from a closed registry, and a typo here would mean a silently unapplied relaxation. The message lists the allowed keys. The comparison is case-sensitive — `Drop-Scope-On-Code-Exchange` is rejected just like a typo. The configuration selects a **key**, never a type or an assembly name: no reflection is ever performed on a configured string. The right to edit configuration does not grant code execution inside the identity provider. ## Quirk registry ### `drop-scope-on-code-exchange` **What it does.** Drops the `scope` parameter from a token request made with `grant_type=authorization_code`. **Why.** RFC 6749 §4.1.3 does not define `scope` in that request, and the strict check rejects it as `invalid_request`. On a code exchange the submitted `scope` has no effect anyway — the granted set comes from the authorization code — so dropping the parameter is equivalent to the client never having sent it. No rights are widened. **Who needs it.** The `openid-connect-generic` plugin for WordPress puts `scope` into the code exchange request. The symptom: sign-in reaches the code exchange and fails with ```json theme={null} { "error": "invalid_request", "error_description": "The 'scope' parameter is not valid in this context." } ``` In WordPress that response is not what the integrator sees: the plugin returns the browser to the site login page and puts the error text into the address — `wp-login.php?login-error=invalid_request&message=The+'scope'+parameter+is+not+valid+in+this+context.` With the quirk enabled the same request passes the form check, and the log gains an entry about the dropped parameter — carrying the `ClientId` and the dropped value. No site-side change is needed: sign-in works on the stock plugin, with no patch installed on the site. **What is untouched.** The `grant_type=refresh_token` path: there `scope` is allowed by RFC 6749 §6 and **narrows** the granted rights. The quirk leaves that path alone — narrowing rights is both legal and useful. **Removal condition.** The upstream fix [`oidc-wp/openid-connect-generic#497`](https://github.com/oidc-wp/openid-connect-generic/issues/497) is released and rolled out by the integrators running the plugin. After that the key is retired along with its handler. While the issue is open, the quirk is needed. ### `drop-resource-parameter` **What it does.** Drops the `resource` parameter ([RFC 8707](https://www.rfc-editor.org/rfc/rfc8707)) from the authorization request and from the token request. **Why.** Veriqa registers no resources, so the strict check rejects any value of that parameter with `invalid_target` and the sign-in fails before it starts. Nothing here consumes the parameter: with no registered resources the granted rights come from the scopes alone, so dropping it yields exactly the token a client that never sent it would receive. The quirk grants no audience and narrows none — it is **not** support for resource indicators. **Who needs it.** The `auth_oidc` plugin for Moodle keeps the Microsoft Entra ID dialect it was written for and sends `resource` on every request, in its generic mode too; there is no setting to switch that off. The symptom: sign-in stops at the authorization request with ```json theme={null} { "error": "invalid_target", "error_description": "One of the specified 'resource' parameters is invalid." } ``` With the quirk enabled the same request passes the form check, and the log gains an entry about the dropped parameter — carrying the `ClientId` and the dropped value. **What is untouched.** The default. Without the key enabled for that specific `client_id`, `resource` is rejected with `invalid_target`. **Removal condition.** Moodle's `auth_oidc` stops sending `resource` in its generic (non-Entra) mode. There is no upstream ticket for that. ## Next Every key, client ones included. What to check before shipping to production. # Declare your own configuration keys Source: https://veriqa.app/docs/guides/config-keys An extension you ship declares its settings at the core, application and ui_config levels through the same declaration catalog the core itself uses. Veriqa resolves a setting through one mechanism: a key is **declared** once — its name, value type, the levels it lives at and the address on each of them — and every read goes through the one resolver, which applies the level precedence and the gate rules of that declaration. That mechanism is not reserved for the core. An extension you ship — a channel adapter of your own, an audit sink, an external admissibility check, anything that arrives as a separate assembly — declares **its own** keys the same way, on the same levels, with the same protective model. This page is the minimal working path. The declaration API lives in `Veriqa.Core.Configuration`. It is part of the MPL-2.0 core the host already runs (it arrives with `Veriqa.Core.AuthServer` and with the channel adapter packages), not of the MIT contracts package — so reference it from the assembly that declares the keys, or declare them in the host. Everything on this page is public API. ## Application and `ui_config` levels are open to extensions The readers of the level records — the one that fetches the OIDC client entry and the one that fetches the `ui_config` record — are `internal`, and an extension does not need them. The owner of a key needs an **address inside** the record, not the record itself: you state where in the record your value sits, and the reader hands the record to the resolution without learning what was read out of it. Both records accept members nobody declared in advance: * **Application level** — an entry of the `Veriqa:OpenIddict:Clients` array in the host configuration. It is read **as a node, by path**: whatever member you write into the entry is readable at the address your key declares, whether or not `OidcClientOptions` happens to carry a property of that name. * **`ui_config` level** — the dynamic record. Fields it does not know are round-tripped through `[JsonExtensionData]`, so your field survives every read and write of the record. What an extension **cannot** do is replace the way a level's record is fetched — put the client entry in a store of its own, for instance. That is a decision of the deployment, not of an extension: the resolution contract has one implementation, and the parts of it that are replaceable are named explicitly at registration. Serving a level from another store is one of them, and it is stated by the host that owns the store (see the package README of `Veriqa.Core.Configuration`), not by a library that declares keys. ## 1. A catalog of your own A catalog holds the declarations of **one owner** — there is no shared catalog for all extensions. Fill it from the static initializer of your key class and expose it: ```csharp AcmeConfigKeys.cs theme={null} using Veriqa.Core.Configuration; public static class AcmeConfigKeys { // Initialized before the declarations that fill it — the order the initializers run in. private static readonly ConfigKeyCatalog Declared = new(); /// Section of the host configuration the core level of these keys lives in. public const string CoreSectionName = "Acme:Approvals"; public static ConfigKey MaxAttempts { get; } = Declared .Of("Acme.Approvals.MaxAttempts") .At(ConfigLevel.Core, CoreSectionName + ":MaxAttempts") .At(ConfigLevel.Application, "AcmeMaxAttempts") .At(ConfigLevel.UiConfig, "acme_max_attempts") .Default(3) .Declare(); public static ConfigKey WindowSeconds { get; } = Declared .Of("Acme.Approvals.WindowSeconds") .At(ConfigLevel.Core, CoreSectionName + ":WindowSeconds") .At(ConfigLevel.Application, "AcmeWindowSeconds") .Default(60) .Declare(); // What the registrar of the deployment reads. public static ConfigKeyCatalog Catalog => Declared; } ``` `Of(name)` opens the chain for a value of any type, `Text(name)` for the text shape most settings have, and `Declare()` closes it: it builds the key, puts the declaration into the catalog and hands the key back. **A chain that is never closed declares nothing** — the key does not exist and no level of it is bound. ### Addresses per level | Form | What it means | | - | - | | `.At(ConfigLevel.Application, ConfigLevel.UiConfig)` | The address **follows from the name**: the dots become path separators, so `Acme.Approvals.MaxAttempts` is read at `Acme:Approvals:MaxAttempts` inside the record of each level. | | `.At(ConfigLevel.UiConfig, "acme_max_attempts")` | The address is written **explicitly** — where the deployed form of the configuration does not match the name of the key. | | `.AtIdentity(ConfigLevel.Tenant)` | The level has **no path**: it is addressed by the identity of the key alone. That is what a store keeping rows "level × owner × key × dimensions" needs — there is no document to walk into. | One rule accounts for more mistakes than any other here: **only the core level is addressed from the root of the host configuration.** Its record *is* the application configuration, so `Acme:Approvals:MaxAttempts` is a full path there. Above the core, an address is relative to the record of its own level — the client entry, the `ui_config` record — and a declaration therefore names no store. An address above the core that starts at a section of the host configuration **stops startup** with a message naming the key, the level and the address. A key whose value is a **composite type** is read from the subtree at its address with strict binding: a member the type does not carry — a leftover of an earlier version, a stray key, a "comment" written as a field — makes the whole subtree unreadable, and the step is then treated exactly like a value the domain rejected (the record is discarded above the core level; at the core level a key declaring `FailStart` stops the host). It is the accepted price of a guarantee: without it a misspelled element of a set arrives as a smaller set that looks perfectly valid. Keys of a scalar value are unaffected, and so are members you add next to your key inside a record you do not own — a client entry is not a subtree of yours. ## 2. Register the catalog from your own composition ```csharp AcmeServiceCollectionExtensions.cs theme={null} public static IServiceCollection AddAcmeApprovals( this IServiceCollection services, IConfiguration configuration) { // Your keys travel with your composition — the one moment that works for an assembly // no composition root can name. services.AddVeriqaConfigKeyCatalogs(configuration, AcmeConfigKeys.Catalog); // ... the rest of your registrations return services; } ``` Called from the host as one line beside the rest: ```csharp Program.cs theme={null} builder.Services.AddVeriqaAuthServer(builder.Configuration); builder.Services.AddAcmeApprovals(builder.Configuration); ``` Three properties of that call are worth stating outright, because they are what make composing independent extensions safe: * **It is idempotent, per catalog.** A catalog the container already holds is skipped **alone**. A composition called twice, and two compositions naming one catalog, both leave exactly one registration. * **It suppresses nothing.** Your call registers what you name and skips what is already there — no owner can knock out the catalogs of another, and none is registered twice. * **Order does not matter.** Every registrar declares its keys *before* the first binding is registered, so whether your composition ran before or after the core's does not decide whether a binding finds its key. The reader of catalogs comes **with** this registration rather than being expected of the host: a catalog nobody reads declares nothing, and its keys would be absent from the schema. `AddVeriqaConfigKeyCatalogs` takes any number of catalogs; `AddVeriqaConfigKeyCatalog` registers one. The shipped code takes both paths — the shared channel adapter package `Veriqa.Core.ChannelAdapter` states its three catalogs in one call, and each channel adapter states its own at its own registration, so a deployment declares the credentials of the channels it added and of no others. ## 3. Read the value ```csharp theme={null} public sealed class AcmeApprovalGate(IConfigurationResolver resolver) { public async ValueTask MaxAttemptsAsync( string? applicationId, CancellationToken cancellationToken) { var resolved = await resolver.ResolveAsync( AcmeConfigKeys.MaxAttempts, ResolutionContext.Of(tenantId: null, applicationId, uiConfigSelector: null), ConfigDimensionValues.None, cancellationToken); // resolved.SourceLevel says which level answered — useful in your own diagnostics. return resolved.Value; } } ``` `IConfigurationResolver` is the only entry point to resolution and is asynchronous: a level served by an external store is read asynchronously, and a synchronous wrapper over it would be sync-over-async. `ResolutionContext.ForTenant(tenantId)` covers the tenant-only case; `ResolutionContext.Core` asks the core level alone. Resolving several keys served by the same record? Open a `ConfigResolutionScope` over the context and pass **it** in place of the context — the record is then read **once** for everything resolved inside: ```csharp theme={null} using var scope = new ConfigResolutionScope(ResolutionContext.ForTenant(tenantId)); var attempts = await resolver.ResolveAsync( AcmeConfigKeys.MaxAttempts, scope, ConfigDimensionValues.None, cancellationToken); var window = await resolver.ResolveAsync( AcmeConfigKeys.WindowSeconds, scope, ConfigDimensionValues.None, cancellationToken); ``` ## 4. What a declaration can state Your keys get the same model the core's do, not a reduced one. **Level-combining semantics.** Plain value semantics is the default — an upper level overrides a lower one freely: | Chain member | Semantics | | - | - | | *(nothing)* | **Value** — the upper level overrides the lower one freely. | | `.Set(intersect)` | **Set** — the effective value is the intersection of the levels, so a lower level cannot widen what an upper one allows. | | `.ProtectiveCeiling(stricter)` | **Protective ceiling** — downward only stricter; a relaxation needs a gate opened by an upper level. | | `.GatedValue(ConfigLevel.Application)` | **Controlled override** — the listed levels override only where the level below opened a gate; the rest override freely. | **Domain of the value and what a value outside it costs:** ```csharp theme={null} public static ConfigKey MaxAttempts { get; } = Declared .Of("Acme.Approvals.MaxAttempts") .At(ConfigLevel.Core, CoreSectionName + ":MaxAttempts") .Domain(new ConfigValueDomain( admits: static value => value is > 0 and <= 10, boundary: "1…10 attempts", correctAt: "Acme:Approvals:MaxAttempts", onRejected: ConfigValueRejectionPolicy.FailStart)) .Default(3) .Declare(); ``` `SkipStep` (the default) leaves the offending step unset and the resolution moves on to the level below; `FailStart` fails startup at the core level and discards the record above it. The `boundary` text is what an operator reads in the report, and `correctAt` is where they are told to fix it. A value never enters either message. An enum-typed key gets a domain of its members even without this call. **Dimensions.** `.Identity("channel")` states the dimension without which the question loses its meaning ("the setting of *which* channel") — it is addressed at every step of the fallback chain and never drops out. `.Narrowing("theme", "surface")` states narrowing attributes, most significant first; they drop out of the chain one by one from the least significant. `.Fallback(...)` replaces the generated chain where a key tries only a subset of the combinations. **The rest of the chain, in one list:** | Member | What it states | | - | - | | `.Default(value)` | What the **core** level answers where its record states nothing — the value the product ships with. It is data, not a hook, so the schema and the snapshot report print it too. | | `.Required()` | Some level must state a value. | | `.Secret()` | The value is a secret: the schema records that a value is set, never the value itself. | | `.Cached(ConfigCachePolicy.ExternalStore)` | Caching policy — a 30-second TTL for a level behind an external store; `ConfigCachePolicy.None`, or a `Ttl` of your own. | | `.BlankIsUnstated()` | A level stating a blank text states nothing at all — a half-finished edit is not an override. Text keys only. | | `.PresenceMeans(value)` | What a level answers by the mere existence of its record. | | `.EnumTokens()` | The value is written as the tokens of an enum. | | `.Parse(...)` | Manual reading of one step — for one value standing over several members of a record (a path and its SRI hash), or a step reading a different leaf than its neighbor. | | `.Stated(...)` | Every address the level may state this setting at, for the walk of a configuration snapshot. Changes no resolution. | | `.NotWalked()` | The walk of a snapshot cannot reproduce this value; the report names it as standing outside instead of reporting half of it. | ## 5. Check that the declaration was picked up The startup report of the mechanism prints what the deployment declares. One `Information` line names the whole schema: ``` Configuration schema declares 68 keys: Acme.Approvals.MaxAttempts, AuthPage.Instruction, ... ``` Set the mechanism's log level to `Debug` and every declaration is printed with its boundaries — the line for the key above reads: ``` Configuration key Acme.Approvals.MaxAttempts (Value): levels UiConfig, Application, Core, gated none, dimensions none, cache none, domain 1…10 attempts. ``` Two discrepancies are named there as well: keys declared without a single extraction binding in this deployment (`Information` — which parts a deployment composes is its own decision), and a `Warning` per "key + level" pair where the key declares a level this deployment serves and no binding was registered for it, so that level stays silently empty. A key that never appears in the report at all was not registered — the usual cause is a chain left unclosed (no `Declare()`), or a composition that never called `AddVeriqaConfigKeyCatalogs`. ## An example in the shipped code `CorePageBrandingConfigKeys` and `LoginConfirmationConfigKeys` live in `Veriqa.Core.ChannelAdapter` — an assembly that *cannot* reference the auth server, a reverse dependency — and they declare keys on the application and `ui_config` levels whose records the auth server fetches. They register their catalogs from the channel adapter package's own composition, exactly as §2 above describes. ## Where this path is needed * **A channel adapter of your own** — its credentials, texts and limits per tenant. The [channel adapter guide](/docs/guides/custom-channel-adapter) resolves settings through `IConfigurationResolver`; this page is where the key it resolves comes from. * **An audit sink, a metrics exporter, an outbound gateway** — anything shipped as a separate assembly that an operator has to configure per application or per tenant. * **Your own restriction policy engine.** Veriqa's own admissibility rules are an internal contract and are not published as an extension point: an integrator who needs another policy engine — Rego, Cedar, a DSL of their own — ships that check themselves, and it keeps **its** configuration on **its** keys, declared exactly as above. ## Next steps Every section and key of the product, and the table of declared keys. The other half of the extension path — the channel SPI. # Build your own channel adapter Source: https://veriqa.app/docs/guides/custom-channel-adapter Plug a channel Veriqa does not ship — KakaoTalk, Zalo, an internal messenger — into your host without forking the core. Veriqa ships eight channels, but the channel layer is an open extension point. If your users live in a messenger that is not on the list, you write an adapter against the MIT contracts package and register it in your host. No fork of the server core, no patch, no pull request to wait for. An adapter is one class and one registration call: ```csharp Program.cs theme={null} adapters.AddChannel( "acme-chat", sp => new AcmeChatChannelAdapter(options, sp.GetRequiredService>())); ``` From that single call Veriqa registers the adapter, maps its webhook endpoint with the same protections as the built-in channels, and shows the channel in the sign-in window. A complete runnable example lives in `samples/dotnet/custom-channel` — an adapter library plus a host that wires it up. Not writing .NET, or running the standard Veriqa image you do not rebuild? Write the adapter as a service of its own in any language and plug it in by configuration — see [An HTTP channel adapter in any language](#an-http-channel-adapter-in-any-language). ## 1. The adapter project Reference the contracts package and nothing else from Veriqa: ```xml Acme.Chat.Adapter.csproj theme={null} ``` `Veriqa.Core.Contracts` is MIT-licensed and *is* the free SPI. What the package contains, and which neighbors by namespace stay in the MPL-2.0 core, is listed in the package's own README (also shown on its NuGet page). The server core (MPL-2.0) is a dependency of the *host*, not of your adapter. ## 2. Implement `IChannelAdapter` Three members carry the flow; the rest are outbound helpers your platform may or may not have. ### `ValidateWebhookAsync` — the security gate Runs before your processing code — though not before everything: the request first passes through the tenant demultiplexer, which answers **403** on an unknown tenant segment without calling your adapter at all, and only then does Veriqa read the body and build the envelope. Return `false` and the request is answered with **403** and never reaches your processing code; an exception thrown here is treated exactly like a rejection (logged, answered **403**), so a broken signature parser can never surface as a `500`. Verify the platform's signature here, in fixed time, and reject by default: ```csharp theme={null} public Task> ValidateWebhookAsync(ChannelInboundRequest request, CancellationToken cancellationToken = default) { if (string.IsNullOrEmpty(_options.WebhookSecret)) { return Task.FromResult(Result.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.Success(isValid)); } ``` ### Declare the inbound verification level of your channel — and document it Your `ValidateWebhookAsync` returning success tells the core only that "a check ran and passed". **What** the check was, the core does not know and cannot derive: it holds no closed list of channels, and a statement the adapter makes about itself would guarantee nothing — the declaration comes from whoever carries the risk: the integrator. So your channel's section carries the same required `InboundVerification` key the shipped channels do: ```json appsettings.json theme={null} { "Veriqa": { "Channels": { "acme-chat": { "InboundVerification": "shared_secret" } } } } ``` The allowed values are `signature` (the body is signed with a platform key), `shared_secret` (a secret presented by the incoming request), `outbound_fetch` (there are no incoming requests, an outgoing one fetches the events) and `none` (authenticity is not verified); the full description is in the [configuration reference](/docs/reference/configuration). **For a channel plugged in the way this guide shows, the key is required but not checked at startup.** The check that refuses to start a host when an enabled channel has no value walks the channels declared at core level — the very set the core builds its enabled channel list from. An `AddChannel` call registers the adapter and its route, not a core-level declaration, so such a channel is not in that set — until you register one yourself, which [working per tenant](#working-per-tenant) has you do, and from that point the check covers your channel like a shipped one. The value is still read by the same resolver and still reaches the audit record, so a missing declaration shows up as an empty attribute in the audit log rather than as a refusal to start. **It is the adapter author's duty to document which value their channel yields** in every mode it supports. The integrator picks the value from your documentation: they do not read your validation themselves, and the core does not derive it. The example above says `shared_secret` because the validation shown above compares a header with a configured secret; a channel that fetches its events with an outgoing request of its own declares `outbound_fetch`. ### `ProcessInboundEventAsync` — the raw body in, an intent out Both methods receive the **same envelope**, `ChannelInboundRequest`: the HTTP method, the raw **bytes** of the body, the headers and the query parameters (names compared case-insensitively, repeated values joined with `", "`). Bytes really are bytes: a signature is computed over exactly those, with no decoding step standing between the platform and your HMAC. Veriqa does not interpret the body format — JSON, form-encoded, XML, whatever your platform sends; parsing it with your own serializer options is your job. ```csharp theme={null} public Task> ProcessInboundEventAsync(ChannelInboundRequest request, CancellationToken cancellationToken = default) { var payload = JsonSerializer.Deserialize(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" }; // The transaction identifier arrives as a string in the channel event, so the adapter is what // parses it: the result field is typed, and an empty identifier cannot reach it by construction. // Does not parse — the event is not ours: ChannelUnrelatedResult, never an "empty id". if (!TransactionId.TryParse(payload.TransactionId, out var transactionId)) { return Task.FromResult(Result.Success(new ChannelUnrelatedResult())); } return Task.FromResult(Result.Success( new ChannelAuthStartResult { TransactionId = transactionId, Identity = snapshot })); } ``` The intent is the **type** of the result, and each one brings its own mandatory set of fields: `ChannelAuthStartResult` (the user reached your platform from the deep link), `ChannelAuthStartByCodeResult` (they sent the code shown on the sign-in page — see [Start by a code](#start-by-a-code-—-when-the-link-cannot-be-relied-on)), `ChannelAuthConfirmResult` / `ChannelAuthDeclineResult` (they pressed a button in your channel), `ChannelPhoneSharedResult` / `ChannelPhoneStepActionResult` (answers to a [phone number request](#phone-number-on-request)), `ChannelUnaddressedResult` or `ChannelUnrelatedResult`. The last two are close and easy to confuse. `ChannelUnaddressedResult` is a **direct message to your bot from a sender you have already vetted** — a bare start command, free text — that names no transaction; it carries the sender, so Veriqa can answer it when the registration declares a reply (`UnaddressedReplyText` below). `ChannelUnrelatedResult` is everything Veriqa has no business answering: a group chat, a callback of somebody else's button, an event that failed your own sender check. It carries no sender, and nothing is sent back. Next to it sits a synchronous `bool OwnsInboundEvent(ChannelInboundRequest request)` — a cheap answer to "is this a Veriqa update?" **before** anything is parsed. You need it when you own the channel transport and hand updates out between your own bot and Veriqa: no event gets parsed twice (this and the other ways to share a bot — [You already have a bot](/docs/guides/existing-bot)). A default implementation is already provided — it looks for any `CallbackDataPrefixes` marker (`vq_confirm_`, `vq_decline_`, `vq_phone_skip_`, `vq_phone_cancel_`, `vq_menu_`, `auth_`) in the raw bytes of the body — so implementing the member is optional. `vq_menu_` marks a button of the [post-completion menu](/docs/reference/configuration#veriqacompletionbehavior): what follows it is a value from the deployment's configuration, not a transaction identifier. The answer is one-sided: an event from a Veriqa button or deep link always answers `true`, but `true` on its own does not promise the event is actionable — the search is substring-based and `auth_` carries no vendor prefix, so the word `oauth_` in a user's message answers `true` as well. Override the member with a precise check of your own wire format if a false positive costs you something. ### `Capabilities` — what your channel can do A channel declares what it can do as a **fact**, not as a stubbed-out operation: one `ChannelCapabilities` object states whether the channel can confirm a sign-in **inside itself** (`SupportsInChannelConfirmation`), which content kinds it accepts, whether it can update its own message (`SupportsMessageUpdate`), whether it shows the user where a transaction stands (`DeliversOutcomeNotice`), whether it supplies the recipient's locale (`ProvidesRecipientLocale`) and whether it renders emoji (`RendersEmoji`). Veriqa never calls what a channel has not declared — so "cannot" is written once, in one place: About confirmation you state exactly one fact: your own ability to ask the question and receive the answer inside the channel. How you do that (inline buttons, a web view inside the messenger, anything) is your business — the core neither knows nor asks. An adapter never names a surface of the **core**: whether a confirmation is needed at all is the core's policy, and the page that asks the question when the channel cannot is the core's own. The fact defaults to "cannot": an already compiled adapter that knows nothing about it will not be handed a confirmation it cannot display. ```csharp theme={null} public ChannelCapabilities Capabilities => new() { // false — cannot confirm inside the channel; the core's page asks the question. // Leaving the property out says the same thing: this is its default value. SupportsInChannelConfirmation = false, SupportedMessageKinds = new[] { ChannelMessageKind.PlainText }.ToFrozenSet(), SupportsMessageUpdate = false, DeliversOutcomeNotice = false, ProvidesRecipientLocale = false, // null — say nothing, and the core words the receipt as it does for a platform that is not // guaranteed to render emoji. true — your channel renders them, and the terminal text is // worded the way it is for a messenger. RendersEmoji = null }; ``` `RendersEmoji` is the one three-state fact of the object: it decides which of the two in-channel wordings a terminal receipt is addressed with, and that is the **channel's** rule rather than the render point's. Leaving it out says "no rule of my own" — the core then answers from its own list of the messengers it ships an adapter for, which names no third-party channel. ### Outbound operations `SendMessageAsync(ChannelMessage)` delivers a message and returns a reference to what was sent (`ChannelMessageRef`) — or `null` in a success when the channel has no addressable reference. `SendConfirmationPromptAsync` sends the confirmation prompt and returns the same kind of reference; Veriqa stores it, not you. `ReportOutcomeAsync(TransactionOutcomeNotice)` states the intent "show the user where this transaction stands" — which platform calls that takes is your decision, and it is called only if your channel declared `DeliversOutcomeNotice`. The notice also carries `DisplayIntent` — where the deployment **wants** the receipt to appear: in place of the message that asked the question (`ReplacePrompt`, the shipped value) or as a message of its own (`NewMessage`). Honoring it is optional: your platform may have nothing editable to replace, and showing the outcome the only way it allows is not a failure — nothing is logged as an error and the result you return does not change. The notice is terminal in every case but one. `Outcome` is `confirmed`, `declined`, `expired` or `failed` when the transaction has ended, and `awaiting_claims` when the sign-in is confirmed and waits for the details your application still asks for; `IsTerminal` tells the two apart. Show that state the way you show a receipt: answer the press, put `StatusText` where the question was. A sign-in that then ends confirmed, declined or failed is reported again, always as a new message. The state is reported for the press that confirmed the sign-in. A press made later, while the sign-in is still waiting, brings the same notice only to a channel that declared `SupportsMessageUpdate`. A channel that cannot edit its messages is not called for it: the state would arrive as a new message under the request of the step in progress and could take that request's buttons away. Instead, while the sign-in waits for the phone number, such a channel is sent the phone number request once more: on many platforms the press itself lands in the chat as a message from the user and removes the buttons of the request, and the repeated request brings them back. If your platform expects an answer to every button press, give that answer yourself when you receive the event. An expiry is reported for every transaction started from your channel, whether the question was asked in the chat or not. `PromptMessage` is set only when the question went into the chat, your channel declared `SupportsMessageUpdate` and returned a reference from the prompt. Without it — the question was asked on the web page, the sign-in needed no question, or there was no reference to keep — show `StatusText` as a new message. The only expiry that goes unreported is one whose record Veriqa no longer has: by default it is kept in the memory of the instance that handled the start ([channel stores](/docs/guides/storage#channel-stores-multiple-replicas)). Honoring `NewMessage` is **two** steps rather than one, and the second is the one easy to miss: deliver the receipt as a new message **and** take the buttons off the message that asked the question, leaving its wording alone. Skip it and the user is left looking at a settled transaction with a live button under it — a question that answers nothing and still invites a press. Take the buttons away with whatever operation changes a message's buttons without rewriting its text; if your platform has no such operation, leave the question untouched rather than blanking its text — an unhonored intent is not a failure, a question that lost its wording is one. Whether the buttons came off decides nothing about the result you return: the delivery of the receipt does. ### Phone number on request A channel that can give the user's phone number declares it in `Capabilities.PhoneNumber`: how it gets the number and whether the platform proves that the number belongs to the sender. What the core does with the number — when it asks, how long it waits, what the token carries — is described in [Phone number](/docs/guides/phone-number). ```csharp theme={null} public ChannelCapabilities Capabilities => new() { SupportedMessageKinds = new[] { ChannelMessageKind.PlainText, ChannelMessageKind.PhoneNumberRequest }.ToFrozenSet(), PhoneNumber = new PhoneNumberCapability { // Automatic — the number comes with the user's messages; OnRequest — the channel asks for it. // Both may be declared at once. Acquisition = PhoneNumberAcquisition.OnRequest, // Proven only when the platform itself binds the number to the account that sent it. Proof = PhoneNumberProof.Proven } }; ``` Leaving `PhoneNumber` out means `PhoneNumberCapability.None`: the channel gives no number, and a sign-in that requires one does not offer the channel. With `Automatic` you put the number, in E.164, into the snapshot's `PhoneNumber` and say whether it is proven in `PhoneNumberVerified` (left `null`, the claim goes out as `false`). With `OnRequest` the core calls `SendMessageAsync` with a message of kind `PhoneNumberRequest`: `Text` is the request, and `PhoneNumberRequest` carries the label of the share button (`ShareButtonLabel`), the second action (`SecondaryAction` — `Skip` or `CancelSignIn`) with its label (`SecondaryButtonLabel`) and the `TransactionId`. The texts arrive localized. Show the platform's "share contact" button and the second button next to it: a button with a payload carries `CallbackDataPrefixes.PhoneSkip` or `CallbackDataPrefixes.PhoneCancel` followed by the transaction identifier, and a keyboard button that can only send text sends its label. Return the answers as: * a shared contact — `ChannelPhoneSharedResult` with the number in E.164 with the leading plus and the sender's snapshot. Normalizing the number to E.164 is the adapter's job: platforms rarely promise that format, and the core counts a number outside it as absent. `E164PhoneNumber.TryNormalize` from `Veriqa.Core.Contracts` does that. When the contact fails your platform's ownership check, set `OwnershipRejected = true`: the core does not take the number and asks again. A contact names no transaction — the core finds the request by the chat; * the second button with a payload — `ChannelPhoneStepActionResult` with the parsed `TransactionId` and `Action`; an identifier that does not parse makes the event `ChannelUnrelatedResult`; * the second button as plain text — `ChannelUnaddressedResult` with `Text`: the core compares it with the label it sent into that chat. When the sign-in ends — by its outcome or by expiring — the terminal notice of `ReportOutcomeAsync` carries `PhoneRequestMessage` — the reference your `SendMessageAsync` returned for the request. Take down what the request offered, its buttons or its input keyboard, by the means of your platform. The reference is absent when you returned none, and a notice of `awaiting_claims` never carries it: the step is still running. If you route updates with `OwnsInboundEvent`, override it: the default recognizes the button markers, but a shared contact carries none. Answer `true` for a contact event and keep the marker check with `CallbackDataPrefixes.ContainsAnyMarker(request.Body.Span)` — the default body of an interface member cannot be called from your class. A button that sends plain text carries no marker at all, so in this mode its press reaches your own bot: an `Optional` number is then skipped when its window runs out, and a `Required` one waits until the transaction expires. The built-in Telegram adapter has the same limitation. ### Claim names — which are yours and which belong to the core Everything you know about the user goes into the **typed fields** of the snapshot: name, username, locale, phone and whether it is proven (`PhoneNumberVerified`), email and whether it is verified (`EmailVerified`) — each has its own field. The core issues the claims from them; `email_verified` goes out only next to a non-empty email, `phone_number_verified` only next to a non-empty phone number. Free-form names live in `AdditionalClaims`, and there the three-group discipline applies (registry `VeriqaClaimTypes`): | Group | Names | What happens | | - | - | - | | Mandatory | `channel_type`, `channel_user_id`, `name` | always issued by the core; never taken from `AdditionalClaims` | | Reserved for the core | `given_name`, `family_name`, `preferred_username`, `locale`, `phone_number`, `phone_number_verified`, `email`, `email_verified`, `picture`, `is_bot` | derived from snapshot fields; never taken from `AdditionalClaims` | | Adapter namespace | any name prefixed with `{ChannelType}_` — for example `acme-chat_workspace` | accepted as is | A key that breaks the rule is **dropped with a warning in the log** — authentication does not fail. So you cannot claim `email` or `locale` with a string of your own: put the value in the snapshot field. Names are compared **case-insensitively** — `Email_Verified` is the same name as `email_verified`, so re-casing a letter does not get around the reservation. Prefixing with your own channel type makes collisions between adapters impossible. Letter case decides **whose** name it is, but grants no freedom in writing it: a claim of your own is taken only in the declared spelling. With `ChannelType = "acme-chat"` the key `ACME-CHAT_workspace` is dropped — write the prefix the way the channel type is written (`veriqa_channel` likewise, as the registry spells it). What follows the prefix is yours: `acme-chat_Workspace` goes through. But two keys of your own differing only in letter case are **one** claim: one of them is taken, the other goes to the log as a warning. Which spelling survives is **not defined**: the set reaches the check frozen, and its enumeration follows the layout rather than the order you wrote it in. Send one spelling — a case-variant pair means losing one of the values. ### Error codes — from the declared registry, not strings of your own You return a failure as `Result.Failure(code, message)`, and `code` comes from `VeriqaErrorCodes` — the code registry declared in the same MIT package. A string of your own looks like it works, but the core does not understand it: it branches on declared codes and tags telemetry with them, while an unknown code stays a line in a log. A body in an unrecognized format is `UnsupportedEventType`; a body without the identity fields is `IdentityExtractionFailed`. The meaning of each code is in the XML documentation of `VeriqaErrorCodes`. One code stands apart: `ChannelCannotContinue` — "I cannot carry **this** confirmation out". Return it from `SendConfirmationPromptAsync` when your channel physically cannot ask the question for this transaction, and the core **ends the transaction in failure** with that same reason code instead of waiting for its TTL. The user sees the error in the browser right away, and nothing reaches them in the channel. Any other failure code means "it did not work right now": the transaction stays waiting for a retry or its TTL. You report no reason and do not pick what happens next — you declare your own inability, the core decides. ### `GetDeepLinkAsync` — where the button and the QR code lead The link you return is where your channel takes the user. The channel button always opens it as is. The QR code carries it too — unless [hop mode](/docs/reference/configuration#veriqahopmode) is on for your channel (it applies only to a channel [declared in the host](#6-register-it-in-the-host)): then the QR code carries a short one-time hop link to your Veriqa server, and opening it on the phone leads to the same link your method returned. Nothing in your adapter changes for hop mode — the core wraps the link above your contract. Carry the transaction id in the link so your platform can post it back on the webhook: ```csharp theme={null} public Task> GetDeepLinkAsync(TransactionId transactionId, CancellationToken cancellationToken = default) => Task.FromResult(Result.Success($"https://chat.acme.example/start?tx={Uri.EscapeDataString(transactionId)}")); ``` **Keep the call idempotent and free of side effects.** A repeated call for the same transaction returns the same target and creates no state: it issues no new secrets, writes nothing to a store and publishes no events. The core calls the method every time it shows your channel — on the sign-in page, on the page where the user of a confirmation picks a channel, in the response of the [server-to-server confirmation API](/docs/reference/confirmation-api) — and, in hop mode, again every time a person opens the hop link. A method that, say, mints a fresh one-time code on each call turns every page refresh and every scan into another live secret. The core cannot check this in your implementation — the promise is yours to keep. ### Start by a code — when the link cannot be relied on Two kinds of platform need more than a link. One has no link that both opens the chat with the bot and carries a payload, so the transaction cannot ride in it at all. The other has such a link but does not always deliver what it carries. A channel on either declares a **start by code**: the core gives the transaction a short code of decimal digits, shows it on its own pages, the user sends it to your bot as a message, and you hand it to the core. ```csharp theme={null} public ChannelCapabilities Capabilities => new() { // … the facts above … CodeStart = new ChannelCodeStart { // The message your bot expects, {code} standing for the code exactly once. // null — the user sends the code alone. MessageTemplate = "/veriqa {code}", // false — the link only opens the chat, and the code is the only way to start. // true — the link carries the transaction, and the code is a fallback beside it. LinkCarriesTransaction = false } }; ``` `LinkCarriesTransaction` is a fact about **your link**, and the core decides the rest from it: | | `false` — the code is the only start | `true` — the code is a fallback | | - | - | - | | `GetDeepLinkAsync` returns | a link that only **opens the chat** and carries no transaction | the link carrying the transaction, as without the declaration | | The core's pages show | in place of the channel button — the message to send, always visible, and a button that copies it and opens your link | the usual button and QR code, and under them a block with the message and a button that copies it. The block appears when it may be needed — on a computer or a tablet 8 seconds after the page loads, on a phone behind a **Problem signing in** link once the user has tapped your channel's button and come back — and only while your channel has not answered: once the start of the sign-in arrived through the channel and the question went into the chat, the sign-in window hides the block and does not offer it again. The integrator switches the block off with `SpareStartCodeEnabled: false` in the [section of the channel](/docs/guides/channels#settings-every-chat-channel-shares) | | The QR code | carries a hop link to your Veriqa server **whether or not** [hop mode](/docs/reference/configuration#veriqahopmode) is switched on; opening it on the phone shows a page with the same message and button, with no automatic launch — `HopRedirectStyle: Direct` does not apply | as for any channel: hop mode applies as configured. Once the user has opened your channel on the phone, the sign-in window shows the block under its instruction 6 seconds later, and the launch page of the `Interstitial` redirect style offers the code behind a **Problem signing in** link, with one button that copies it and opens your link. With a hop QR code on the page the 8 seconds above do not apply to your channel: the block waits until the phone opens your channel | | [Server-to-server confirmation API](/docs/reference/confirmation-api) | the channel is left out of `channel_entries` and is never the `deep_link` entry: the code is not part of that contract, and the link is useless without it. The `page_url` entry leads to the page that shows the code | returned as before | The two conditions of a hop link stay in force for the first column: the channel is [declared in the host](#6-register-it-in-the-host), and the issuer is an absolute HTTPS URL. Where either is missing, the QR code carries your link — it opens the chat, and the user reads the code off the sign-in page. A host where such a channel is declared and enabled maps the hop route even with hop mode off. Whatever the user sent comes back to you on the webhook. Strip your command and hand over the rest: ```csharp theme={null} return Task.FromResult(Result.Success( new ChannelAuthStartByCodeResult { Code = code, Identity = snapshot })); ``` The core finds the transaction by the code and carries on exactly as after `ChannelAuthStartResult`. What belongs to whom: * **The format of the message is yours.** The core substitutes the code into `MessageTemplate` and shows the result; it does not interpret the wording. A template that is blank, longer than 64 characters (`ChannelCodeStart.MaxMessageTemplateLength`) or does not hold `{code}` exactly once cannot be used: a channel with `LinkCarriesTransaction = false` is then left out of the page, like a channel that failed to build its link, and a channel with `true` is shown without the block. * **The code is the core's.** Do not normalize it and do not check its length: the page shows it in groups of three digits, the user may send it with spaces or dashes, and the core drops both. The length is one setting of the installation, `Veriqa:CodeStart:CodeLength` — an integer from 6 to 10, 6 by default; a value outside the range stops the host at startup. One code serves the transaction on every channel that declares the start, works once and lives exactly as long as the transaction. * **The attempt limit is the core's.** An event with a code is charged to the same per-user budget as a start by the link, before the code is looked up. A code that names no live transaction — unknown, expired, already used — is counted like a start whose transaction cannot be served and answered with the text the registration declares for it, `UnmatchedCodeReplyText`: one text for every reason, so the answer does not tell whether the code ever existed. A registration that declares none answers with `StaleLinkReplyText`, or with silence when it declares neither. * **A message with a code carries no Veriqa marker**, so the default `OwnsInboundEvent` does not claim it. Override the member if you route updates by it. A code is shared by the whole installation: it is unique among all live codes, whichever tenant the sign-in belongs to, and it is found whichever webhook address the message arrives at — with the tenant's segment or without it. All tenants draw from one space of codes, so an installation with many sign-ins in progress at the same time should raise `Veriqa:CodeStart:CodeLength`. Of the shipped channels, Instagram and Messenger declare the fallback: their link carries the transaction, and the platform does not always deliver it. The [HTTP channel](#an-http-channel-adapter-in-any-language) declares the start by configuration — see [The start by a code](#the-start-by-a-code). Both modes can be tried in `samples/dotnet/custom-channel`: its README walks through the start by code (`AcmeChat:StartByCode`) and the fallback (`AcmeChat:CodeFallback`). ## 3. Name your channel The channel type is a contract, not a display string. It must match: ``` \A[a-z][a-z0-9-]{0,63}\z ``` A lowercase URL-safe token starting with a Latin letter — `kakaotalk`, `acme-chat`, `zalo`. The value is matched ordinally against `acr_values` and becomes a URL path segment, so anything else (`KakaoTalk`, `my_channel`, an empty string, a value with a trailing newline read from configuration) **fails the startup** with a message naming the allowed format. The same fail-fast applies to a duplicate registration of a type — which covers a type a shipped channel already claimed, because the shipped channels are registered through this very `AddChannel` and live in the same registry — and to the type passed to `AddChannel` differing from the one your adapter returns from `ChannelType` — the two must be equal, because everything downstream matches the channel by the adapter's `ChannelType`. That last check runs on host start, not on the webhook mapping, so an outbound/polling-only channel registered with `mapWebhook: false` is diagnosed just as strictly. That last check has one boundary: a cross-wired swap — one channel's adapter registered under another channel's type — is caught by cross-checking the adapters' runtime types. If your host layers a sweeping decorator over **every** `IChannelAdapter` (Scrutor `Decorate()` or a Castle proxy over the whole set), every adapter shares the decorator's single runtime type and the wrapped types are hidden behind it: such a swap becomes indistinguishable from a legitimate decorator and is **not** rejected — swap detection is best-effort in that configuration. It stays exact for native adapters and for per-adapter class proxies. The plain case — the type passed to `AddChannel` is declared by no adapter at all — is always rejected, decorators or not. ## 4. The webhook endpoint Registering a channel maps ``` POST /api/channels/{channelType}/webhook POST /api/channels/{channelType}/webhook/{tenant} ``` with **the same rate-limit policy and the same 1 MB body limit** as the built-in channel routes — the generic route is not a way around the protections applied to `telegram` or `whatsapp`. Answer semantics: | Situation | Answer | | - | - | | Unknown tenant segment in the path | `403`, before your adapter is called | | Body over the 1 MB limit — the read aborts before validation | `200` + a log entry | | `ValidateWebhookAsync` returns false / fails / throws | `403` | | Channel not registered | `404` | | `GET` on the generic path — the channel declared no verification handshake | `405` | | Everything after a successful validation | `200` | That last row is absolute: a `Failure` result and even an unhandled exception from your adapter are logged and still answered `200` with an empty body. The rule is the same for every channel: the routes of the shipped channels are served by the same handler, so an adapter exception does not turn into a `500` there either. Messaging platforms disable webhooks or storm retries on non-2xx, and the response body never leaks error details. The 1 MB limit belongs to the request body: Veriqa reads the body itself, once and entirely into memory, while building the envelope — after the tenant demultiplexer and before validation. An over-limit body aborts that read as it arrives, without being read to the end; a failed read is not a rejection verdict, so the answer is `200` plus a `Warning` log entry, **the same on the generic route and on the built-in ones**. The over-limit update is lost, but the platform is not pushed into endless redelivery; the symptom is the log entry, not the status code. If your channel is outbound-only or polls the platform itself, register it without a route: ```csharp theme={null} adapters.AddChannel("acme-chat", mapWebhook: false); ``` ### Registration settings `mapWebhook` is the one setting the short overloads spell out. Everything a registration can state lives in `ChannelRegistrationOptions`, which both overloads accept in place of the flag — the adapter type and the factory form alike: | Property | Default | What it states | | - | - | - | | `MapWebhook` | `true` | Whether Veriqa maps the generic webhook route for the channel. `false` — an outbound/polling-only channel, or one that maps an inbound route of its own through `IChannelEndpointRegistrar`. | | `ErrorMessageText` | `"Something went wrong. Go back to the sign-in page and start again."` | The Natural Key of the status text shown to the user when processing fails. It is the one status text a registration states: a terminal transaction outcome is a message of the mechanism and is resolved by address, while an error your channel reports is not an outcome of the transaction at all. | | `StaleLinkReplyText` | `null` | The Natural Key of the reply to a sign-in start whose transaction cannot be served — it is gone, its window has closed, or (where the core web page asks the question) a decision on it is already recorded. `null` — your channel stays silent. A start on a sign-in that is confirmed and waits for the details the application still asks for gets this reply only on a channel that declares `SupportsMessageUpdate`: elsewhere a new message would take away the buttons of the request in progress, so the reply is not sent — and while the sign-in waits for the phone number, the phone number request is sent once more instead. One text covers every reason: the wording does not tell "never existed" from "expired". The reply goes out under the same per-user budget as the sign-in start itself, charged once per event. | | `UnmatchedCodeReplyText` | `null` | The Natural Key of the reply to a [start by a code](#start-by-a-code-—-when-the-link-cannot-be-relied-on) that no live transaction answers to — unknown, expired, already used. One text covers every reason; word it so that it tells the user what to do: check the code on the sign-in page and send it again, or open the page again. `null` — the reply is `StaleLinkReplyText`, so a channel that stays silent on a stale link stays silent on such a code. Declare it when your channel starts by a code: a text about an expired link confuses a user who followed none. | | `UnaddressedReplyText` | `null` | The Natural Key of the reply to a direct message that names no transaction (`ChannelUnaddressedResult`). `null` — no reply. | | `WebhookVerificationQueryKey` | `null` | The query parameter carrying the challenge of the platform's verification handshake (below). `null` — the platform has none, and no GET route is mapped. | An empty or blank string means the same as `null`: the channel stays silent. The shipped channels take this very path: their status wording and their handshake are declared here as well. Telegram declares a `StaleLinkReplyText`, WhatsApp does not: a message outside an approved template is chargeable there, so answering every stale link would cost the installation money. For a channel of your own, decide by its platform's pricing. `UnaddressedReplyText` is declared by Instagram and Messenger: a first tap there may arrive without the transaction, and the reply tells the user to send the code shown on the sign-in page or to scan the QR code again. Their adapters hand over only a tapped button as `ChannelUnaddressedResult` — a typed message that concerns no sign-in is `ChannelUnrelatedResult` and gets no answer — and each has a setting that switches the reply off ([Instagram](/docs/guides/channels#instagram), [Messenger](/docs/guides/channels#messenger)). The other shipped channels leave it `null` and send nothing. `UnmatchedCodeReplyText` is declared by the same two channels, the ones that start a sign-in by a code. ### The platform's verification handshake (GET) Platforms of the Meta family confirm ownership of a webhook by sending a **GET** to the same path with a challenge to echo back. Declare the query parameter that carries it, and Veriqa maps the GET route beside the POST one: ```csharp theme={null} adapters.AddChannel( "acme-chat", new ChannelRegistrationOptions { WebhookVerificationQueryKey = "hub.challenge", ErrorMessageText = "Acme Chat is unavailable" }); ``` What makes the handshake genuine is **yours**: the GET is validated by the same `ValidateWebhookAsync` as a POST, so the verify token, the spelling it arrives under and the mode check are your adapter's business. Veriqa only knows which query parameter holds the value it has to echo. The route is fail-closed, exactly like the POST one: | Situation | Answer | | - | - | | Unknown tenant segment, or no adapter registered for the channel | `403` | | `ValidateWebhookAsync` returns false / fails / throws | `403` | | Validation passed, but the declared query parameter is missing or empty | `400` | | Validation passed | `200` with the challenge as the body | Declare nothing and the GET route is not mapped at all — the path answers `405`, which is the behavior a channel whose platform has no handshake wants. ## 5. Show the channel in the sign-in window A registered channel already appears as a button — by default labeled with its raw channel type and without an icon. Implement the optional `IChannelDisplayMetadata` to give it a name and a glyph: ```csharp theme={null} public sealed class AcmeChatChannelAdapter : IChannelAdapter, IChannelDisplayMetadata { public string DisplayName => "Acme Chat"; public string? IconSvgPath => """"""; } ``` `DisplayName` is a single non-localized string — brand names are not translated, exactly like "Telegram"; a blank label — or one longer than 32 characters, which would break the window layout — degrades to the raw channel type. `IconSvgPath` is the inner markup of a `24×24` viewBox and is embedded into the page, so it must be static markup you own — never user input. It is checked against an allowlist before rendering: plain shape elements only (`path`, `circle`, `ellipse`, `rect`, `line`, `polyline`, `polygon`, `g`) with double-quoted attributes, no `style` and no `on*` handlers, and at most 4096 characters. Anything else is dropped with a warning in the log and the channel renders without an icon. A channel without a brand color renders with the neutral primary color of the window. If the user sees your channel's chat under a name of its own — a bot username, an account handle — implement the optional `IChannelBotName` as well: ```csharp theme={null} public sealed class AcmeChatChannelAdapter : IChannelAdapter, IChannelDisplayMetadata, IChannelBotName { public ValueTask GetBotNameAsync(CancellationToken cancellationToken = default) => ValueTask.FromResult("@acme_bot"); } ``` The core puts the value, as it is, into the texts that tell the user which chat to look at: the caption over the [code](#start-by-a-code-—-when-the-link-cannot-be-relied-on) and the line that tells the user to confirm in the chat — the hop launch page shows it once it has taken the user into the channel, and the sign-in window shows it once your channel has answered the start of a sign-in. The method is called under the tenant of the transaction, as `GetDeepLinkAsync` is. `null`, a blank value, a value longer than 32 characters or an exception produces the same texts, worded without a name — the channel name is never put in its place. **A configured name comes first.** The `BotName` key in the [section of the channel](/docs/guides/channels#settings-every-chat-channel-shares) is read before your adapter is asked, and a name stated there is shown instead of the one `GetBotNameAsync` returns — the method is then not called at all. So a channel that does not implement `IChannelBotName` still gets a name on the pages when the integrator states one. Status messages your channel sends back to the user come from Veriqa's own localized defaults — you do not have to supply them. For a sign-in these are "Sign-in confirmed" — or "Sign-in confirmed. Tap Back to return to the sign-in page." when the sign-in was started on a phone or a tablet — and "Sign-in declined"; for a confirmed action they are the neutral outcome receipts "Confirmed ✅", "Declined ❌" and "Time expired ⌛". The error text for a failure while handling the answer — "Something went wrong. Go back to the sign-in page and start again." — is the channel's own and does not depend on the transaction type. ### QR code size The sign-in window draws every channel's QR in a box sized by the `--veriqa-qr-size` CSS token (220px by default). You override it from your own stylesheet, whose path goes into `Veriqa:AuthPageDesign:CustomCssPath`: ```css theme={null} /* globally — for every channel */ :root { --veriqa-qr-size: 260px; } /* for your channel only — the panel id is built from the channel type */ #veriqa-panel-acme-chat { --veriqa-qr-size: 260px; } ``` Override the token itself — it is the supported point of control over the size. A rule such as `#veriqa-panel-acme-chat .veriqa-qr img { width: … }` gets the same result by bypassing the token and is therefore forbidden: the next change to the window layout will drift it out of step with the other sizes. You may not shrink the QR below either of two thresholds: * **200×200px** — the minimum physical target size for a camera; * **2.86px per module** — the density: `box size ÷ the QR's total module count including the quiet zone`. There are two criteria and meeting one does not substitute for the other: a long deep link is encoded by a higher QR version, so at the same box size the module shrinks past readability even though the 200px rule is formally satisfied. The box size is only half the picture; the other half is the resolution of the source PNG — its pixels per module. There is a single rule, and you have to check it **right away, not only if you enlarged the box**: ``` pixels per module ≥ box size ÷ your QR's module count (the quiet zone included), rounded up ``` Otherwise the image is stretched and its module grid blurs. **Why this applies to the shipped 220px box too.** The `PixelsPerModule` default of 6 is sized for the deep links the core ships: the most compact of those QRs is 45 modules, and `45 × 6 = 270px` covers the box. In a 220px box the default suffices from `⌈220 ÷ 6⌉ = 37` modules up. A QR only gets sparser than that from a **very short** link: `acme://c/8f3k9d` — 33 modules, `33 × 6 = 198px`, already stretched in the shipped box. The link from §2, the one that carries the transaction id (43 characters), does not fall into that case — it comes out at 45 modules and the default covers it. So you need the entry below only if you keep a short code of your own with your own mapping to the transaction. The host starts either way: the value is inside the allowed range, and your QR's module count is unknown to the core — you are the only one who can check the rule. If that is your case, the fix is an entry for your own channel: ```json appsettings.json theme={null} { "Veriqa": { "AuthPageDesign": { "QrCode": { "PixelsPerModule": 6, "PixelsPerModuleByChannel": { "acme-chat": 8 } } } } } ``` The 8 here is `⌈220 ÷ 29⌉`: 29 modules is the most compact QR that can exist at all (version 1 with the quiet zone), so 8 covers the shipped box whatever the length of your deep link. If you enlarge the box, recompute this value with the same formula. The allowed range is **5…35**. The upper bound is that same formula applied to a 1000px box and the same 29 modules, so the range covers any box that fits on a screen, for any channel. A value outside it stops the host at startup — but landing inside the range confirms nothing in return: satisfying the formula is still up to you. ## 6. Register it in the host The host is the application that embeds Veriqa, so it references the server core: ```csharp Program.cs theme={null} builder.Services.AddVeriqaAuthServer(builder.Configuration, builder.Environment, authServer => { // OpenIddict store: the volatile in-memory one, chosen explicitly (there is no default — // a host that says nothing here fails to start outside Development). authServer.UseInMemoryOpenIddictStore(); authServer.ConfigureTransactionEngine(te => te.UseInMemoryStore()); authServer.AddChannelAdapters(adapters => { adapters.AddTelegram(); adapters.AddChannel( "acme-chat", sp => new AcmeChatChannelAdapter(acmeChatOptions, sp.GetRequiredService>())); }); }); ``` Your channel's configuration section belongs to you: Veriqa neither knows nor binds it. Bind it in the host and pass the result to your adapter — and keep the secrets in user-secrets or a secret store, not in `appsettings.json`. Use `AddChannel("acme-chat")` instead of the factory overload when the adapter's dependencies can be resolved from the container. **Declare the channel next to `AddChannel`.** `AddChannel` registers the adapter and its route, not the channel's core-level declaration (`CoreChannelDeclaration`) — that is a separate step, the same one the shipped channels take. The declaration is what puts your channel into `channels_enabled`, the set of channels available at core level, and everything that reads that set skips a channel missing from it: [hop mode](/docs/reference/configuration#veriqahopmode) never wraps its QR code, polling never gets a tenant to run for, and the startup check of inbound verification stays off it. The host still starts — with a warning in the log naming the channel that has an adapter but no declaration. How to declare it is shown in [Working per tenant](#working-per-tenant). ## 7. Add the channel to the supported list An adapter that ships as part of Veriqa gets registered in one more place: the [Supported channels](/docs/guides/supported-channels) catalog and its version on the site (`veriqa.app/channels`). That list is maintained by hand — a channel missing from it is not a supported channel, however many other places mention it. An entry states the same three things as every other one: which claims the channel adds **on top of the mandatory ones**, whether confirmation happens inside the channel, and whether the user sends a secret message — or the messenger hands the code to the bot by itself. A channel still in progress goes under the in-development or planned groups, not the built-in one. None of this applies to an adapter of your own that stays with you — the list describes what Veriqa ships. Still, keep an entry like it in your own documentation: people ask the same questions about any new channel. ## Working per tenant Multi-tenancy is inside this SPI, not outside it. A channel of your own reads a tenant's credentials, texts and limits the same way the shipped ones do — through the same public pieces, and under the same one rule: **the tenant is stated by whoever legitimately knows it, and stated once.** **The set of tenants comes from `IPollingTenantSource`.** Resolve it from the container and ask it which tenants your channel is active for; a `null` element is the default implicit tenant of a self-hosted installation. It has one precondition: the set is built from the core-level **declarations** of the registered channels, and `AddChannel` does not register one — it registers the adapter and its route. So declare your channel in the host, next to the `AddChannel` call: ```csharp Program.cs theme={null} builder.Services.AddSingleton(sp => new CoreChannelDeclaration( "acme-chat", isEnabled: () => sp.GetRequiredService>().CurrentValue.Enabled, usesPolling: () => sp.GetRequiredService>().Value.UsePolling)); ``` Both facts are read through delegates, and the shipped channels close them over exactly these two accessors. `isEnabled` is asked again every time the available set is built, so it has to read a source that survives a re-read of the configuration: a monitor, not an options object bound once — that one would freeze a deployment-side edit out of the answer. `usesPolling` is a startup decision, and `IOptions` is what the shipped channels read it through. Both need the section in the container (`builder.Services.Configure(builder.Configuration.GetSection("AcmeChat"))`), which is one line more than the manual binding in §6 does for the adapter itself. That `isEnabled` is also what puts your channel into `channels_enabled` at core level. Without the declaration the tenant set comes back empty and no polling starts — and the startup check the note in §2 describes stays off your channel; with it, an enabled channel without an `InboundVerification` value stops the host exactly like a shipped one. **State the tenant with a scope, around the processing, on your own background path:** ```csharp theme={null} foreach (var tenantId in tenants) { // The tenant of THIS iteration: everything resolved below it — credentials, texts, limiter // keys — reads that tenant's levels. using var scope = ChannelTenantContext.BeginScope(tenantId); await ProcessUpdatesAsync(tenantId, cancellationToken); } ``` A health probe of your own runs outside any request and reports on the installation itself, so it states the default tenant **explicitly** — `ChannelTenantContext.BeginScope(null)` — rather than by omission: a credential read with no scope open at all is indistinguishable from a path that forgot to state its tenant, and nothing downstream can tell the two apart for you. **On the webhook you do not open a scope.** The tenant of an incoming request is already stated — by the route (`/api/channels/{channelType}/webhook/{tenant}`), before your adapter is called. One of your own opened there would repoint every credential read below it, the core's included, at a tenant of your choosing instead of the one the host named. **You resolve your own credentials; you do not receive them.** Declare your key and resolve it through the public `IConfigurationResolver` with `ResolutionContext.ForTenant(tenantId)` — the same canonical resolver and the same level order the shipped channels go through. The public client factory `IChannelClientFactory` is not needed for that: it builds and caches a *client* per `(channel, tenant)`, which is a different job. How a key of your own is declared — the catalog, the address on each level, the registration — is in [Declare your own configuration keys](/docs/guides/config-keys). ## An HTTP channel adapter in any language Everything above is the .NET path: your adapter runs inside the host. The second path needs no .NET and no host code at all. The **HTTP channel** is an adapter shipped with Veriqa that stands in for yours inside the host and forwards the work over HTTP to an **external adapter** — a service you write in any language and run next to Veriqa or inside your own perimeter. The channel is added by configuration. The wire contract between Veriqa and an external adapter is version `1.0` and ships with the status **`preview`**. Declaring it stable is a separate decision; until then a revision of the contract may still change it. ### How the traffic flows * **The platform talks to your adapter, not to Veriqa.** Your adapter receives the platform's webhooks or polls it, verifies them and answers the platform's challenge handshake itself. Veriqa maps no GET route for an HTTP channel. * **Your adapter posts events to Veriqa** on the routes every channel has, `/api/channels/{channelType}/webhook` or `/api/channels/{channelType}/webhook/{tenant}`. An event of a transaction goes to the route of **that transaction's tenant** — the one the `tenant` field of the `deep-link` and `send-prompt` calls of that transaction carried; `null` means the route without a tenant segment. Veriqa does not tell you the tenant again: keep the mapping "transaction → tenant" yourself. * **Your public entry is yours to protect.** Veriqa's rate limit guards its own routes; the entry your adapter opens to the platform needs protection against junk traffic of its own. * **Veriqa calls your adapter at one address**, `Http:BaseUrl`, for everything it needs from the channel. * **The platform credentials stay with your adapter.** Veriqa never asks for them and never stores them. * **One registration is one external adapter**: one address, one transport secret, one set of channel facts. Without `Http:Tenant` it serves every tenant of the installation. A tenant that brings an implementation of its own gets a registration of its own, under its own name (for example `shop-a-zalo`), with `Http:Tenant` naming that tenant. * **The standard Veriqa image always registers HTTP channels**: a channel is added with environment variables and a restart of the container, without rebuilding the image. An embedded host references the `Veriqa.Core.ChannelAdapter.Http` package and adds one call: ```csharp Program.cs theme={null} authServer.AddChannelAdapters(adapters => adapters.AddHttpChannels()); ``` ### Configuration Every section `Veriqa:Channels:{channelType}` with an `Http` subsection becomes one HTTP channel under the name of the section: ```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 } } } } } } ``` The same channel in environment variables, with the transport secret — keep it there or in a secret store, not in `appsettings.json`. A channel type with a dash cannot be exported from a shell, but the `environment:` of a compose file and `docker run -e` take it: ```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_ Veriqa__Channels__acme-chat__Http__Capabilities__SupportsInChannelConfirmation=true ``` What to know about the keys (the full list is in the [configuration reference](/docs/reference/configuration#keys-in-the-declaration-catalog)): * **The channel facts and the button are stated by the operator**, with `Http:Capabilities:*` (one key per property of `ChannelCapabilities`), `Http:DisplayName` and `Http:IconSvgPath`. There is no network manifest: Veriqa does not ask your adapter what it can do. A fact left unstated takes the safe default of `ChannelCapabilities` — every boolean `false`, `plain_text` as the only content kind, no emoji rule of the channel's own (`RendersEmoji` unset), no phone number ([The phone number](#the-phone-number)). * **`Http:Tenant`** names the tenant that owns the registration. It serves that tenant alone; for every other tenant the channel does not exist — not on the inbound route, not for events, not in outbound calls. Unset, the registration serves every tenant of the installation. * **`Http:AdapterSettings`** is an opaque string, up to 8192 characters, that Veriqa forwards verbatim with every call and never reads. It has a core level and a tenant level; the value of a tenant replaces the core value entirely. * **`Http:AllowInsecureHttp`** admits an `http` base URL. Use it only for an isolated internal network (a neighboring container); Veriqa writes a warning at startup. * **Every `Http:*` value is fixed at startup**, except `Http:AdapterSettings`, which is resolved per call. Changing the others takes a restart. * **The name of the channel** follows the format of [Name your channel](#3-name-your-channel), and the names of the channels shipped with Veriqa are reserved even when the shipped channel is not wired into the host: `telegram`, `whatsapp`, `max`, `email`. The reserved list grows as new built-in channels ship. * **A configuration error found at startup stops the host**, with a message naming the key or the channel section — for example, a missing or relative base URL, an `http` base URL without `Http:AllowInsecureHttp`, a missing `Http:TransportSecret`, a secret not in the `whsec_` form, a secret shared by two registrations, a forbidden deep-link scheme, a timeout not in the `hh:mm:ss` form, `Http:AdapterSettings` longer than 8192 characters, a reserved name or one another channel of the host already has. An empty `Http:Tenant` is an error too: to serve every tenant, remove the key. Unlike an `AddChannel` channel (the note in §2), `InboundVerification` **is** checked at startup for an HTTP channel: the registration declares the channel to the core, so an enabled HTTP channel without a value stops the host. #### The phone number A channel that can give the user's phone number says so in `Http:Capabilities:PhoneNumber` — the same fact a built-in channel declares in code (see [Phone number on request](#phone-number-on-request) and [Phone number](/docs/guides/phone-number)). Left unstated, the channel gives no number and is not offered to a sign-in that requires one. | Key (under `Http:Capabilities:PhoneNumber`) | Values | Unstated | | - | - | - | | `Acquisition:{n}` | a list: `Automatic` — the number comes with the identity of your events, nothing is asked; `OnRequest` — Veriqa asks for it with a `send-message` of the kind `phone_number_request`. Both may be listed | none — the channel gives no number | | `Proof` | `Proven` — a number from this channel is proven to belong to the user; `NotProven` | `NotProven` | ```json appsettings.json theme={null} "Capabilities": { "SupportedMessageKinds": [ "plain_text", "phone_number_request" ], "PhoneNumber": { "Acquisition": [ "OnRequest" ], "Proof": "Proven" } } ``` ```bash theme={null} Veriqa__Channels__acme-chat__Http__Capabilities__SupportedMessageKinds__0=plain_text Veriqa__Channels__acme-chat__Http__Capabilities__SupportedMessageKinds__1=phone_number_request Veriqa__Channels__acme-chat__Http__Capabilities__PhoneNumber__Acquisition__0=OnRequest Veriqa__Channels__acme-chat__Http__Capabilities__PhoneNumber__Proof=Proven ``` The names are matched without regard to case. Five mistakes stop the host from starting: `PhoneNumber` written as a single value instead of a section, a value outside the names above, `Acquisition` written as a single value instead of a list, `Proof: Proven` with no `Acquisition`, and `OnRequest` without `phone_number_request` in `SupportedMessageKinds` — the request would never reach your adapter. `Proof` is the **ceiling** of what your events may state. `identity.phone_number_verified: true` of an event is taken only from a registration that declares `Proven`; from any other, Veriqa lowers it to `false` and writes a warning. `false` and an absent field are never raised: declaring `Proven` does not make a number proven that your adapter said nothing about. #### The start by a code A channel whose link cannot carry the transaction, or whose platform does not always deliver it, declares the [start by a code](#start-by-a-code-—-when-the-link-cannot-be-relied-on) in `Http:Capabilities:CodeStart` — the same two members a built-in channel sets in code. The core shows the code on its pages, the user sends it to your bot, and your adapter posts it with the event `auth_start_by_code` (see [The wire contract](#the-wire-contract)). | Key (under `Http:Capabilities:CodeStart`) | Values | Unstated | | - | - | - | | `LinkCarriesTransaction` | `false` — your `deep-link` returns a link that only opens the chat, and the code is the only way to start; `true` — the link carries the transaction, and the code is a fallback beside it. **Stating this key declares the start by a code** | no start by a code | | `MessageTemplate` | the message the user sends, `{code}` standing for the code exactly once | the user sends the code alone; an empty value counts as unstated | ```json appsettings.json theme={null} "Capabilities": { "CodeStart": { "LinkCarriesTransaction": false, "MessageTemplate": "/veriqa {code}" } } ``` ```bash theme={null} Veriqa__Channels__acme-chat__Http__Capabilities__CodeStart__LinkCarriesTransaction=false Veriqa__Channels__acme-chat__Http__Capabilities__CodeStart__MessageTemplate="/veriqa {code}" Veriqa__Channels__acme-chat__Http__UnmatchedCodeReplyText= ``` `Http:UnmatchedCodeReplyText` is the reply to a code that names no live sign-in; unset, `Http:StaleLinkReplyText` answers instead. The template is judged where the channel is shown, as for a built-in channel, not at start. Three slips stop the start: `CodeStart` written as a single value instead of a section, `LinkCarriesTransaction` that is not `true` or `false`, and `MessageTemplate` without `LinkCarriesTransaction`. With `LinkCarriesTransaction: true` the spare code block and its setting `SpareStartCodeEnabled` work for the HTTP channel as for any channel. An `auth_start_by_code` from a registration that does not declare the start is not processed. For now the start by a code is open only to a registration without an owner. A registration that declares it and sets `Http:Tenant` stops the host at start, with a message naming both keys. ### The wire contract Every call from Veriqa is a signed `POST` of a JSON envelope to `Http:BaseUrl`: `version`, `operation`, `channel_type` (one entry may serve several registrations), `tenant` (or `null`), `adapter_settings` (or `null`) and `payload`. The answer is `200` with an envelope of `version` and exactly one of `result` or `error` (`code` and `message`). | Operation | What your adapter does | | - | - | | `send-prompt` | Sends the confirmation question with two buttons. Veriqa passes the text it rendered, the button captions and the structured context, so you may render on your own | | `send-message` | Sends a message to the user. Its `kind` is one the registration declares. The kind `phone_number_request` comes with the member `phone_number_request` — the captions of the share button and of the second button, the secondary action (`skip` or `cancel_sign_in`) and the transaction; how you show the two is your choice. For every other kind the member is `null` | | `report-outcome` | Shows the user where a transaction stands: `outcome` is its final state or `awaiting_claims` — the sign-in is confirmed and waits for the details the application still asks for. A final state may carry `phone_request_message`, the phone number request to take down. `expired` is reported for every transaction started from the channel — with `prompt_message` set to `null` when the question was not asked in the chat or `send-prompt` returned no reference. `declined` also comes with nothing pressed — when the user cancels the sign-in on the sign-in window while its question waits in the chat — with `prompt_message` set to `null` when `send-prompt` returned no reference. Called only when the channel declares `DeliversOutcomeNotice`; for a press on a sign-in that is already waiting, `awaiting_claims` is sent only when the channel also declares `SupportsMessageUpdate` | | `deep-link` | Returns the `url` that takes the user into your channel for a transaction | | `health` | Reports whether the adapter is healthy, its version and the highest wire version it speaks | | Event | When your adapter posts it | | - | - | | `auth_start` | The user reached your channel through the deep link of a transaction | | `auth_start_by_code` | The user sent your bot the code of the sign-in page — only for a registration that declares [the start by a code](#the-start-by-a-code). `code` is what the user sent, with the wrapping of your `MessageTemplate` stripped and nothing else done to it: Veriqa drops spaces and dashes and checks the length. The event names no transaction | | `auth_confirm` | The user pressed the confirm button | | `auth_decline` | The user pressed the decline button | | `phone_shared` | The user shared their phone number. `phone_number` is in E.164 with the leading plus — `+` and 1 to 15 digits, the first not zero, no separators or spaces. Veriqa does not repair a number in another spelling: the event is not processed, the answer is still `200`, and a warning goes to the log | | `phone_step_action` | The user chose the second action of a phone number request — through a control that carries a payload. `action` is `skip` or `cancel_sign_in`, as the request named it; `transaction_id` is the one of the request | | `unaddressed` | A direct message from a known sender that names no transaction. The optional `text` carries the text of the message: a button that carries no payload arrives as the text of its caption, and that is how such a platform answers the second action of a phone number request | | `unrelated` | Anything Veriqa has no business answering | The fields of every payload, result and event are in the machine-readable [JSON Schema of the wire](#conformance-suite) — this guide does not repeat them. Rules the schema does not express: * **Error codes.** The codes the wire names are in the schema, and any other code is accepted too. The code of an `error` is written to the log and decides nothing else, with one exception: `channel_cannot_continue` in the answer to `send-prompt` ends the transaction in failure right away. Any other `error` acts by its operation, and the call is not repeated after it: * `send-prompt` — the transaction stays open and waits for a new `auth_start` from the user or the end of its lifetime; * `deep-link` — the user gets no way into your channel: the sign-in page is rendered without it; * `send-message`, `report-outcome` — the refusal is logged and counted in the metrics, nothing more. * **`deep-link` is idempotent**: a repeated call for the same transaction returns the same `url` and creates no new state. * **Answers are bounded.** An answer is read up to 1 MB and refused past it, redirects are not followed, and a deep link is accepted only as an absolute URI with the `https` scheme or a scheme listed in `Http:DeepLinkSchemes`, no longer than 2048 characters. * **Timeouts and retries.** One attempt waits `Http:OperationTimeout` (10 s by default); `deep-link` waits `Http:DeepLinkTimeout` (2 s) because it stands on the path of the sign-in page. `send-prompt`, `send-message` and `report-outcome` are retried on a transport failure — no connection, a timeout, a `5xx`, a `429` — up to three times, all attempts within `Http:RetryBudget` (20 s). Nothing else is retried: a `200` with `error`, a `3xx`, any other `4xx`, an oversized or malformed answer. `deep-link` and `health` are attempted once. * **The second action of a phone number request comes back in either of two ways**, whichever your platform allows: the `phone_step_action` event, or an `unaddressed` event whose `text` equals the caption of the second button. An `action` Veriqa does not know makes the event `unrelated`. The share button itself is answered with `phone_shared`. * **The phone number of an identity.** `identity.phone_number` of any event is taken as the user's number — the automatic way — and `identity.phone_number_verified: true` only up to the `Proof` the registration declares (see [The phone number](#the-phone-number)). * **`auth_start_by_code` goes on the route your bot's other events use.** It names no transaction, so the rule of the transaction's tenant does not apply to it: Veriqa looks the code up across the whole installation, whichever route the event came on, and takes the tenant from the transaction it finds. * **Refuse an event envelope on your call entry.** A body with `event` instead of `operation` that arrives at `Http:BaseUrl` is an event reflected back at you; answer it with anything but a `200` with `result`. * **Serve calls while your own request is open.** On `auth_start` Veriqa sends `send-prompt` to your adapter **before** it answers your `POST` of the event. An adapter that cannot take calls concurrently with its own requests to Veriqa times the prompt out, and the transaction waits for its lifetime. ### The signature Both directions are signed with the transport secret of the registration by the [Standard Webhooks](https://www.standardwebhooks.com/) scheme `v1` (HMAC-SHA256) — every language has a library for it. The headers are `webhook-id`, `webhook-timestamp` and `webhook-signature`, and a timestamp is accepted within 5 minutes either way of the receiver's clock. * **Rotation without downtime.** Put the new secret in `Http:TransportSecret` and the old one in `Http:PreviousTransportSecret`: the sender then signs with both, and the receiver accepts either. Veriqa writes a warning at startup until the previous secret is removed. * **Answers are not signed**: their authenticity rests on the connection Veriqa opened to the configured address — which is why `https` is the default. * **A wrong signature is `403`**, without an envelope and without any effect, in both directions: Veriqa answers your unsigned or mis-signed event that way, and your adapter must answer such a call the same way. * **Every registration has a secret of its own.** Two registrations sharing one would let one pass as the other, so the host refuses to start. ### Idempotency * **The `webhook-id` of `send-prompt`, `send-message` and `report-outcome` is the idempotency key.** A repeat with a key you have already served successfully sends nothing new and returns the result of the first call — the same message reference. `deep-link` and `health` carry a unique `webhook-id` that is not a key. * **The `webhook-id` of your event is yours to choose**: unique per event and unchanged when you deliver the same event again. Veriqa derives the keys of the `send-prompt` and `report-outcome` an event causes from it, so a redelivered event does not ask the question twice. * **A failed attempt does not bind the key**: the next attempt with it is served anew. * **Retention is the operator's choice**, within two floors: no shorter than the retry budget of Veriqa (`Http:RetryBudget`), and, for a key derived from an event, no shorter than the window in which your adapter itself may deliver that event again. ### Wire version and the `preview` status Every envelope, in both directions, carries `version` — the wire version of its sender, `major.minor`; the first is `1.0`, with the status `preview`. Within one major version changes are additive only — a new optional field, operation, enumeration value or event type — and each comes with the behavior of a receiver that does not know it: an unknown field is ignored, an unknown operation is answered with `unsupported_operation`, an unknown question variant is shown as a question without details, an unknown event is taken by Veriqa as `unrelated`. Removing or renaming a field or changing its meaning takes a new major version. Answer a call of another major version with `unsupported_wire_version`; Veriqa, in turn, does not process an event of another major version and takes such an answer as outside the contract. Every event also carries `adapter_version` — the semver version of your adapter. ### Conformance suite Veriqa ships an executable conformance suite that checks your adapter against the wire, whatever it is written in. It checks three things: * **calls through the real wrapper** — every operation, a repeat under the same key, a wrong secret and a timestamp outside the window. These calls are made by the very HTTP channel code Veriqa runs, so the suite cannot drift from Veriqa; * **malformed calls** Veriqa never makes — unsigned, with an unknown field, variant or operation, of a foreign major version, an event envelope reflected at your call entry — sent by a separate sender; * **the receiver of events** — it plays Veriqa for your events: checks the signature and the schema of each, calls `send-prompt` before it answers `auth_start`, and checks that every event came on the route of its transaction's tenant. Two scenarios check the phone number, and each joins a run only when the registration declares its way: `send-message.phone-number-request` for `OnRequest`, `identity.phone-automatic` for `Automatic`. A scenario missing from the report means its way is not declared. An event that states `phone_number_verified: true` without a declared `Proof: Proven` fails `events.all-valid`. A registration that declares [the start by a code](#the-start-by-a-code) gets the scenario `flow.code-start`: the suite makes up a code, has it sent to the bot of a deep link — by the platform stub with `send-text`, or by you on an `ACTION` line — and expects `auth_start_by_code` with those digits from the user of the run. With `LinkCarriesTransaction: false` the scenarios that start a transaction by its link — `flow.confirm`, `flow.decline`, `identity.phone-automatic` — are left out of the run: a link that only opens the chat starts nothing. The suite then does not play the confirmation after a start by a code — its made-up code has no transaction behind it — so check that path with a live run. The suite is a container image, built from the root of the repository: ```bash theme={null} docker build -f src/core/Veriqa.Core.ChannelAdapter.Http.Conformance/Dockerfile -t veriqa-http-channel-conformance . ``` Run it next to your adapter with the configuration of your channel in the same `Veriqa__Channels____*` keys Veriqa reads, plus keys of its own under `Conformance__*`: `ChannelType` and `ChannelUserId` are required; `Mode`, `Tenants` and `ReportPath` are optional. | `Conformance__Mode` | What runs | For | | - | - | - | | `calls` (default) | the calls alone | the CI gate of your adapter | | `interactive` | the calls and the receiver; you open the deep link on the platform and press the buttons yourself | a manual check of the event half | | `automatic` | the calls, the receiver and a stub of the platform that plays the user; `Conformance__PlatformStubUrl` is required | adapters that come with such a stub; its contract is in the suite's README | In `interactive` and `automatic` the suite stands in for Veriqa: it listens on port `8080` of the image on the same paths, `/api/channels/{type}/webhook` and `/api/channels/{type}/webhook/{tenant}`, so your adapter changes only the base address of Veriqa it posts to. How long it waits for an event is `Conformance__EventTimeoutSeconds`. The suite prints one line per scenario — `PASS `, `FAIL : ` or `N/A : ` — writes a JSON report to `Conformance__ReportPath`, and exits with `0` (no scenario failed), `1` (at least one failed) or `2` (the run could not be carried out; the message names what to fix). The full list of scenarios is in the README of `src/core/Veriqa.Core.ChannelAdapter.Http.Conformance`. The machine-readable schema of the wire (JSON Schema) ships with the suite: at `/schema/http-channel-wire-v1.schema.json` in the image and in `src/core/Veriqa.Core.ChannelAdapter.Http.Conformance/Schema/` in the repository. ### Reference adapters Three external adapters in the repository show the whole contract on a real platform: * `samples/python/custom-channel-telegram/` — Telegram, in Python; * `samples/node/custom-channel-whatsapp-twilio/` — WhatsApp through Twilio, in Node; * `samples/node/custom-channel-whatsapp-baileys/` — WhatsApp through Baileys, in Node — a working adapter for debugging, small teams and your own use; WhatsApp may ban the number. Each runs next to Veriqa from its own folder: ```bash theme={null} cp .env.example .env # fill in the transport secret and the platform credentials docker compose up ``` Between them they show every operation and every event of their channel, the signature in both directions, idempotency (a repeat sends no second message), credentials per tenant, `adapter_settings` read as a setting of the adapter's own, and the protection of the adapter's own public entry. The Twilio adapter also answers `channel_cannot_continue` when the platform cannot deliver the question; the Telegram adapter splits the updates between Veriqa and a bot of your own. Two of them show the two ways of obtaining the phone number: the Telegram adapter **on request** — it shows the request as a keyboard with a contact button and returns the second button as the text of an `unaddressed` event; the Twilio adapter **automatically** — the number of the sender travels in the identity of every event. The Telegram adapter also shows [the start by a code](#the-start-by-a-code) behind a switch, `CODE_START`: `off` by default, `fallback` for a registration with `LinkCarriesTransaction: true`, `only` for one with `false`; its CI runs the suite a second time in the `only` mode. The Baileys adapter is a working one, meant for debugging WhatsApp scenarios, for small teams and for your own use: Baileys is an unofficial WhatsApp Web client on a number of your own, and WhatsApp may ban that number. It asks the question as a poll and fetches the messages over its own outbound session. Each README documents its configuration, its `InboundVerification` value and a manual live run. They are samples to learn from and copy, not a catalog of community adapters. ### `InboundVerification` of an external adapter The rule of [Declare the inbound verification level of your channel — and document it](#declare-the-inbound-verification-level-of-your-channel-—-and-document-it) holds for an external adapter unchanged: the platform's traffic is verified by your adapter, so **you name the value for every inbound mode of your channel** in your documentation, and the operator declares it in the section of the channel. There is no default. The signature of the transport between your adapter and Veriqa is always checked and is not what this value describes. The reference adapters name theirs: | Adapter | Inbound mode | `InboundVerification` | | - | - | - | | Telegram | `webhook` — a secret token in a header of every update | `shared_secret` | | Telegram | `polling` — the adapter fetches the updates | `outbound_fetch` | | WhatsApp through Twilio | the webhook signed by Twilio | `signature` | | WhatsApp through Baileys | the adapter's own outbound WhatsApp Web session | `outbound_fetch` | In the same documentation list the top-level keys of your `raw_metadata` that carry personal data, the native user id of the platform included: the integrator decides from that list what they may keep. # Configuration & customization Source: https://veriqa.app/docs/guides/customization Channels, sign-in page design, localization and Veriqa extension points. The sign-in page and Veriqa's behavior are configured through settings, and key services can be replaced with your own implementation. Below is what integrators touch most often. ## Channels Each channel is wired in `AddChannelAdapters` and activated by the `Veriqa:Channels:{Channel}` section with `"Enabled": true`. Eight channels come out of the box, each as its own package: the base set as the `Veriqa.Core.BaseChannels` metapackage (Telegram, WhatsApp, Email), MAX, Instagram, Messenger, WeChat and Viber separately (`Veriqa.Core.ChannelAdapter.Max`, `Veriqa.Core.ChannelAdapter.Instagram`, `Veriqa.Core.ChannelAdapter.Messenger`, `Veriqa.Core.ChannelAdapter.WeChat`, `Veriqa.Core.ChannelAdapter.Viber`), and a single channel as just that channel's package. See [channel setup](/docs/guides/channels) and [supported channels](/docs/guides/supported-channels). ```csharp Program.cs theme={null} authServer.AddChannelAdapters(adapters => { adapters.AddTelegram(); adapters.AddWhatsApp(); adapters.AddMax(); adapters.AddEmail(); }); ``` Email works in two modes: **Pull** (magic link — the user follows a link from the email) and **Push** (the one-tap email — the user sends a pre-filled message). Push is off by default and is enabled with `Veriqa:Channels:Email:PushEnabled`. The Email channel endpoints are mapped by `MapVeriqaAuthServer` automatically when the channel is enabled — see the [quickstart, step 3](/docs/quickstart/dotnet#3-build-the-middleware-pipeline). Telegram receives updates in `Webhook` mode (default) or `Polling` — set by `Veriqa:Channels:Telegram:UpdateMode`. For `Webhook`, provide `WebhookBaseUrl` and `WebhookSecretToken`. A client can restrict sign-in to a specific channel via `acr_values=channel:{type}` — see the [quickstart](/docs/quickstart/dotnet#9-channel-selection-via-acr-values). ## Email: sending and receiving Email is enabled through two independent modes under the `Veriqa:Channels:Email` section: * **Pull** (magic link) — Veriqa sends an email with a link, the user follows it. On by default (`PullEnabled`). * **Push** (the one-tap email) — the user sends a pre-filled message to the inbound address, Veriqa processes the incoming message. Off by default (`PushEnabled`) — enable it together with the inbound setup and a sender verification policy. The flag is read at startup: restart the service after changing it. ```json appsettings.json theme={null} { "Veriqa": { "Channels": { "Email": { "Enabled": true, "InboundVerification": "shared_secret", "PublicBaseUrl": "https://your-domain.com", "PreferredMode": "Push", "PullEnabled": true, "PushEnabled": false, "Outbound": { "Smtp": { "Host": "smtp.your-provider.com", "Port": 587, "UseSsl": false, "Username": "…", "Password": "…" } }, "Inbound": { "Provider": "Webhook", "InboundAddress": "login@your-domain.com", "UsePlusAddressing": true, "VerificationPolicy": "DmarcAlignedPass", "WebhookSecretToken": "…" } } } } } ``` * `PublicBaseUrl` is required when the channel is enabled — magic links and QR codes are built from it. * `Outbound:Smtp` — SMTP for Pull mode (`Port` 587 = STARTTLS; `UseSsl: true` = implicit TLS, port 465). With credentials set, STARTTLS is mandatory unless `RequireStartTls: false` (default `true`). Keep the password in a secret store, not in `appsettings.json`. * `Inbound` — inbound email for Push mode: `InboundAddress`, `UsePlusAddressing` (embeds the correlation token into an address like `login+{token}@…`), `WebhookSecretToken` to validate the provider webhook. * `VerificationPolicy` — sender verification: `DmarcAlignedPass` (default), `AllowListOnly` (only the domains in `AllowedDomains`). The `None` value is forbidden in production. ## Design presets and theme The page's look is set by the preset in `Veriqa:AuthPageDesign:Preset`: | Preset | Description | | - | - | | `Default` | Standard light theme (default). | | `Dark` | Dark theme. | | `Minimal` | No decorative elements: the brand row is not rendered and the container has no border. The preset does not change what the page contains — QR visibility is set by [`ShowQrCode`](#qr-code-visibility). | | `Branded` | The client's primary color (see below). | The color scheme is controlled separately — `Veriqa:AuthPageDesign:Theme`: | Theme | Description | | - | - | | `Auto` | Follows the OS setting (`prefers-color-scheme`). Default. | | `Light` | Light theme. | | `Dark` | Dark theme. | An explicit `Theme` (`Light` / `Dark`) takes precedence over the preset's color scheme; with `Theme: Auto` the `Dark` preset also yields a dark page. The theme sets `data-theme` on ``, the preset sets `data-preset`. Animations respect `prefers-reduced-motion`. The theme applies to **every** page Veriqa generates, not to the sign-in window alone: the sign-in confirmation page, the page that collects missing details, the expired-link page and the email sign-in pages all carry the same `data-theme`. With `Theme: Auto` they follow the visitor's system setting, so a stylesheet of your own that assumes light colors will get dark ones on a device set to a dark theme — pin the scheme with `Theme: Light` if it must not. ```json appsettings.json theme={null} { "Veriqa": { "AuthPageDesign": { "Preset": "Default", "Theme": "Auto" } } } ``` ## QR code visibility Whether the sign-in page shows the QR code is set by the `ShowQrCode` key of the `Veriqa:AuthPageDesign:QrCode` section — the same section that holds the code's resolution ([configuration reference](/docs/reference/configuration#veriqaauthpagedesignqrcode)): ```json appsettings.json theme={null} { "Veriqa": { "AuthPageDesign": { "QrCode": { "ShowQrCode": "DesktopOnly" } } } } ``` | Value | Behavior | | - | - | | `Always` | The QR code is shown on every device, and several channels are laid out as tabs. | | `DesktopOnly` | The QR code is hidden on a phone, which cannot scan its own screen; there the channels are laid out as buttons. A tablet and a desktop get the tabs and the QR code. Default. | | `Never` | The QR code is never shown — only the channel buttons, on any device. | Where this section says tabs, [`ChannelLayout: Grid`](#channel-layout) puts the channels side by side instead. Two properties of `DesktopOnly` its name does not reveal: * **A tablet counts as a desktop.** The QR code is scanned by *another* device's camera, so a device of uncertain type keeps the code rather than losing it. * **When device detection fails, the QR code is shown.** The device is determined by the page itself, and any failure of that check — a blocked script, an unrecognized User-Agent — leaves the QR code and the tabs in place. A page whose only way in is a button the user cannot reach is worse than a QR code shown where it is useless. The preset does not affect QR visibility: `Preset` and `ShowQrCode` are independent settings. **After an update.** The default used to be `Always`. A deployment that does not set `ShowQrCode` now shows a phone the channel buttons instead of the QR code and the tabs; a tablet and a desktop are unchanged. To keep the previous page, set `"ShowQrCode": "Always"`. ### Channel buttons On a phone under `DesktopOnly`, and on any device under `Never`, the page has no tab strip: the channels are listed as buttons, one per channel, in the order of the channel set. * The "Open in …" button of a messenger leads straight into it. * Email with a mail-client button keeps that button, and the "Sign in via email link" link under it. * Email with an address form gets a button of its own that reveals the form in place. A page whose only channel is this one shows the form straight away — there is nothing to choose. * A [channel hint](/docs/reference/configuration#veriqalocalization-and-veriqachanneldisplay) appears under the button of its own channel. Under `Never` the server renders this layout itself, so it needs no script. Under `DesktopOnly` the page is rendered with tabs and switches to buttons once it has recognized a phone. ### Channel button visibility The QR code is the way into a channel from *another* device; the button that leads into it — "Open in …", and Email's "Send confirmation email" — is the way in on *this* one. Whether that button is shown is set by the `ShowChannelButton` key of the `Veriqa:AuthPageDesign:ChannelButton` section, the counterpart of `ShowQrCode`: ```json appsettings.json theme={null} { "Veriqa": { "AuthPageDesign": { "QrCode": { "ShowQrCode": "DesktopOnly" }, "ChannelButton": { "ShowChannelButton": "MobileOnly" } } } } ``` | Value | Behavior | | - | - | | `Always` | The button is shown on every device. Default: a deployment that sets nothing keeps the page it had. | | `MobileOnly` | The button is shown on a phone only. Every other device — a desktop, a tablet, a television, a kiosk — is left with the QR code, which is scanned with a phone. | | `Never` | The button is never shown: the QR code is the only way into a channel, on any device. | The pair in the example is what a screen read from across a room needs — a television, a kiosk, a projector: the QR code on the big screen, the buttons on a phone, one way in on each. What the setting covers: * **The buttons that lead into a channel:** "Open in …" of every channel, a third-party one included, and Email's "Send confirmation email". With that one hidden, the Email panel keeps its QR code and the "Sign in via email link" link. * **Not the controls that stay on the page:** the address form of Email, the button that reveals that form among the [channel buttons](#channel-buttons) and the links switching the two Email sections are shown whatever the value. * **The sign-in window and the page that opens a confirmation.** The pages a phone gets after scanning a hop QR code keep their buttons whatever the value: there the button is the only way forward. Two properties of `MobileOnly`: * **A tablet does not count as a phone**, exactly as under `DesktopOnly`: it gets the QR code and no button. * **When device detection fails, the button stays hidden** — the opposite of the QR code, which stays shown. A blocked script or an unrecognized User-Agent leaves a phone without the buttons; the QR code, which every allowed pair keeps on such a page, is then the way in. Like `ShowQrCode`, the key can be set at each ownership level, and the nearest level that sets it wins: the `ui_config` record (`ChannelButton:ShowChannelButton` under `Veriqa:UiConfigurations:Records:`) → the client entry (the same member in its entry of `Veriqa:OpenIddict:Clients`) → the global section. The tenant level sits between the last two; as with the [footer block](#footer-block-of-the-sign-in-page), no reader for it is registered out of the box. ### Allowed combinations of the two visibilities The two keys are resolved independently, and each may come from a level of its own — a client entry that hides the buttons over a global section that hides the QR code. Every class of devices must be left with a way in, the QR code or the button: | `ShowQrCode` | `ShowChannelButton: Always` | `MobileOnly` | `Never` | | - | - | - | - | | `Always` | allowed | allowed | allowed | | `DesktopOnly` | allowed | allowed | **not allowed** — a phone has neither | | `Never` | allowed | **not allowed** — every device but a phone has neither | **not allowed** — no device has either | A pair that is not allowed neither stops the host nor breaks the page: the page is rendered as if `ShowChannelButton` were `Always`, and a warning naming both configured values goes into the log — whenever such a page is requested, not once at startup. The check looks at the two settings alone, not at the channels the page carries. ### The instruction follows both settings The built-in instruction under the title names only what the page shows. It goes into the markup in **three wordings** — for a page with the QR code and the button, with the QR code alone and with the button alone — and the stylesheet of the page shows exactly one of them, by the same attributes of the root element that show and hide the QR code and the buttons (`data-qr-visibility`, `data-channel-button-visibility`, `data-device`). | The page shows | One channel | Several channels, a QR code for each | Several channels behind one QR code | | - | - | - | - | | the QR code and the button | `Scan the QR code or tap the button to sign in via {0}` | `Choose a channel and scan the QR code or tap the button` | `Scan the QR code or tap the channel button` | | the QR code alone | `Scan the QR code to sign in via {0}` | `Choose a channel and scan the QR code` | `Scan the QR code to sign in` | | the button alone | `Tap the button to sign in via {0}` | `Choose a channel and tap the button` | `Tap the channel button to sign in` | So a television under `ShowChannelButton: MobileOnly` reads "Scan the QR code…" with no button promised, and a phone under `ShowQrCode: DesktopOnly` reads "Tap the button…" — there is nothing to reword by hand. A browser in which device detection failed gets the wording of a desktop, the same way it gets the desktop's QR code and buttons. One QR code for several channels is what a page shows in [hop mode](/docs/reference/configuration#veriqahopmode). Under `QrMode: Unified` the page has no tabs and uses the wording of the last column. Under `QrMode: UnifiedWithChannels` the QR code sits on the "Any channel" tab, followed by a tab for each channel, and the wording follows the selected tab: a channel tab — one QR code and one button — keeps the last column, while the "Any channel" tab, where the user picks one of several, reads `Scan the QR code you need or tap a messenger button` in place of its first row. With [`ShowChannelButtonsOnAnyChannelTab`](/docs/reference/configuration#veriqahopmode) set to `false`, that tab shows the QR code alone and reads `Scan the QR code to sign in`. The page opens on "Any channel", so a browser with JavaScript turned off shows the wording of that tab. **The tabs of a page with the "Any channel" tab.** "Any channel" takes a row of the tab strip on its own; the channel tabs follow, two to a row, and the last of an odd number spans the whole row. A channel tab whose panel shows that channel's own QR code is labeled with the built-in text `{0} ▦`: the name of the channel goes into `{0}`, and the mark `▦` is drawn as a QR code icon in the color of the tab text, so the user sees which tabs hold a QR code. A tab whose panel shows no QR code of its channel — Email signing in with an emailed link — carries the name alone. The label can be reworded like any other text: a replacement keeps `{0}`, each `▦` in it becomes the icon, and a replacement without the mark shows no icon. **A page of one channel names the channel.** The page fills the `{0}` slot with the icon of the channel followed by its name — the pair its tab would carry; a channel that has no icon is named by the name alone. A page of several channels names none. Each of the ten wordings is a built-in text, so it is reworded like any other: for one application in its [`AuthPage.Texts` dictionary](#rewording-a-page-element-for-one-application), for the whole installation in the locale files. Two things to keep in mind for the three texts with a slot: * a replacement in `AuthPage.Texts` has to carry `{0}`, as every replacement of a text with a slot does; a translation in a locale file that drops it is shown as is, and the channel then goes unnamed; * `{0}` goes in the text, never inside a tag. A wording that puts it into an attribute value — `` — is shown as plain text, with a warning in the log (see [Markup in the title, the instruction and a channel hint](#markup-in-the-title-the-instruction-and-a-channel-hint)). The `Instruction` of a `ui_config` record replaces the instruction whole — one wording for every device, with no channel named. A page whose only channel is Email with an address form shows neither a QR code nor a channel button, yet gets the same one-channel wording; give such a page an `Instruction` of its own. ### The page wording follows whether the QR code is shown Strings that name the code or the button go into the markup in **every wording at once**: the instruction in the three above, the "back to the QR code" link of the Email panel as a pair — "with QR" and "without QR". They are switched by the same conditions as the blocks they describe: rules keyed on the root element's `data-qr-visibility`, `data-channel-button-visibility` and `data-device` attributes — for the pair, the `.veriqa-if-qr` / `.veriqa-if-no-qr` classes. The script of the page takes no part in the choice. That way the page never promises a QR code or a button it does not show. This constrains your own CSS (`CustomCssPath`) and script (`CustomJsPath`): a rule that overrides the `display` of these wordings decouples the text from what the page shows — leaving, say, "scan the code" on a page with no code. If you hide or show the QR code or the buttons by your own means, switch the wordings by the same conditions. The server always generates the QR code, even when the page does not show it: the PNG goes into the markup as a data URI and stays hidden. Under `Never` this only adds to the response size. ## Channel layout Where a page shows several channels, each with a QR code of its own, the channels share the page through tabs — one on screen at a time. The `ChannelLayout` key of the `Veriqa:AuthPageDesign` section puts all of them on screen at once instead: ```json appsettings.json theme={null} { "Veriqa": { "AuthPageDesign": { "ChannelLayout": "Grid" } } } ``` | Value | Behavior | | - | - | | `Tabs` | One channel at a time: a tab strip, and the panel of the selected tab on screen. Default. | | `Grid` | Every channel at once: the panels stand side by side, each under the name of its channel and with its own QR code. Meant for a screen read from a distance — a television, a kiosk — where nobody switches a tab. | Under `Grid`: * **The card grows with the number of channels.** Its width is the usual one column (420px) plus a column for every further channel, and never more than the window less the margins the card always keeps. Panels that do not fit a row wrap onto the next line — down to one per line on a narrow window. * **A panel holds what its tab would show:** the QR code, the channel button (subject to [`ShowChannelButton`](#channel-button-visibility)), the Email form, the channel hint. * **There are no tabs in the markup.** Each panel is a group labeled by the visible name of its channel, so a screen reader announces the channels rather than "tab 1 of 3". The key arranges what the tabs would otherwise hold and nothing else. A page with nothing to arrange is the same under either value: * a page of one channel; * a page laid out as [channel buttons](#channel-buttons) — any device under `ShowQrCode: Never`, a phone under `DesktopOnly`; * in [hop mode](/docs/reference/configuration#veriqahopmode), a page that shows one QR code for all its channels — also where that QR code stands on the "Any channel" tab before the channel tabs (`QrMode: UnifiedWithChannels`): its tabs stay tabs. Which channels the page carries is a different setting — `ChannelDisplay:Mode` and the channel set. The layout applies to the sign-in window and to the page that opens a confirmation. It is set at the same levels as `ShowChannelButton`: the `ui_config` record (`ChannelLayout` under `Veriqa:UiConfigurations:Records:`) → the client entry (`ChannelLayout` in its entry of `Veriqa:OpenIddict:Clients`) → the global section. **Your own stylesheet.** A rule of `CustomCssPath` that sets the width of `.veriqa-container` narrows the card of the grid back to one column, and the row turns into a stack. Write it for the one-column card only: `.veriqa-container:not(.veriqa-container--grid)`. ## Cancel button The sign-in window carries a **Cancel sign-in** button — the way back to the application for a user who has changed their mind. It sits below the channels, the status line and the countdown, above the [footer block](#footer-block-of-the-sign-in-page). The button is **on by default**; the `Enabled` key of the `Veriqa:AuthPageDesign:Cancel` section switches it off: ```json appsettings.json theme={null} { "Veriqa": { "AuthPageDesign": { "Cancel": { "Enabled": false } } } } ``` What pressing it does: * **The sign-in ends as declined by the user** — the transaction fails with the reason `declined_by_user`, the same as a "No" on the web confirmation page. * **The browser returns to your application** — to the `redirect_uri` of the request, with the standard `error=access_denied`, the `state` the request carried and the issuer identifier `iss` ([RFC 9207](https://datatracker.ietf.org/doc/html/rfc9207)), in the response mode it asked for (`query`, `fragment` or `form_post`). Your application handles it like any other declined sign-in. An ASP.NET Core client on `AddOpenIdConnect` has to be told where to send the user: without `AccessDeniedPath` the handler throws on `access_denied` and the return ends in an HTTP 500 — see [step 5 of the .NET quickstart](/docs/quickstart/dotnet#5-wire-up-the-client-application). * **A confirmation arriving from the channel at the same moment does not win:** no authorization code is issued, and the application still receives `access_denied`. * **The chat where the question waits is told:** when the sign-in was waiting for the user's answer to a question already shown in a messenger chat, that chat gets the same "Sign-in declined" receipt a press of the decline button gives. Where the platform can edit a sent message — Telegram, for one — the receipt replaces the question and its buttons go away; elsewhere — Instagram, Messenger, WhatsApp and the like — it arrives as a new message and the buttons of the question stay, a later press being answered with the same receipt. Nothing is sent when the question is not in a chat: with Email, or when it is asked on the web page. Whatever happens to the receipt, the application receives `access_denied`. The button is a plain form, so it works on a page whose script never ran. It disappears from the page once there is nothing left to cancel — the sign-in has expired, was declined or has failed. **Where it is shown.** In the sign-in window only. The page that opens a confirmation has no application to return to, and the page a phone gets after scanning a hop QR code is on another device — neither carries the button, whatever the setting. **Levels.** The nearest level that sets the key wins: the `ui_config` record (`Cancel:Enabled` under `Veriqa:UiConfigurations:Records:`) → the client entry → the global section. To leave one application without the button, set it in that client's entry: ```json appsettings.json theme={null} { "Veriqa": { "OpenIddict": { "Clients": [ { "ClientId": "my-app", "Cancel": { "Enabled": false } } ] } } } ``` **The text** is the Natural Key `Cancel sign-in` — the key the cancel action of the claims completion step already uses, so the translations you have apply to the button as well. To reword the button of the sign-in window for one application, name `Cancel sign-in` in its [`AuthPage.Texts` dictionary](#rewording-a-page-element-for-one-application). **After an update.** The button is on by default, so it appears in every deployment that does not set `"Enabled": false` — nothing has to be configured to get it. If you added a cancel control of your own through `CustomJsPath`, remove it or switch the built-in button off. A [renderer of your own](#sign-in-page-renderer) shows no button until it renders the form itself. **What the button changes in the response of the sign-in window.** The `form-action` directive of its Content-Security-Policy names the origin of the client's `redirect_uri` next to `'self'` (or, for an address with a custom scheme, that scheme): a browser applies the directive to the whole chain of redirects after a form post, and the post of the button ends on the `redirect_uri`. The response also sets the antiforgery cookie and is sent with `Cache-Control: no-cache, no-store`. With the button switched off the response is what it was. ## Branding The client's logo and brand name fill the brand row at the top of the sign-in card in **every preset but `Minimal`**; the primary color applies **only** in the `Branded` preset: ```json appsettings.json theme={null} { "Veriqa": { "AuthPageDesign": { "Preset": "Branded", "PrimaryColor": "#FF5500", "LogoUrl": "https://your-domain.com/logo.png", "BrandName": "Acme", "PrimaryColorByTheme": { "dark": "#FF8A3D" }, "LogoUrlByTheme": { "dark": "https://your-domain.com/logo-dark.png" } } } } ``` * `PrimaryColor` — strictly `#RRGGBB`; an invalid value falls back to a neutral default. It only colors shared elements (the primary button, focus rings, the spinner). * `LogoUrl` and `BrandName` together replace the product's own mark. With neither of them set at any level, the brand row shows the red diamond and `Veriqa`. Setting **either** one replaces that mark as a whole: a name alone shows no diamond, a logo alone shows no `Veriqa`. An unset logo therefore does not mean "no brand row" — for a page without one, use the `Minimal` preset. Pages other than the sign-in window (web confirmation, an expired sign-in, the email pages) show no brand row at all. * `BrandName` — the name beside the logo, in place of the product's own wordmark. The two are resolved **independently**: set either one alone and the page shows what there is — the name alone, the logo alone, or both. Unlike the logo the name has **no per-theme variant** (an image cannot repaint itself for a dark page, text takes the page's color), so `BrandNameByTheme` does not exist. A blank value sets nothing: the name then comes from the level below. The value is plain text — it is encoded on output — and a localization key (see [Texts set in configuration](#texts-set-in-configuration-are-localization-keys-too)). * `PrimaryColorByTheme` and `LogoUrlByTheme` set the value for a single theme (`light`, `dark`). A theme without a value of its own takes the neighboring shared key (`PrimaryColor`, `LogoUrl`). With `Theme: Auto` the browser picks the theme, and both values — the light one and the dark one — travel to the page. * Messenger brand colors are **never** recolored, in any preset. **After an update.** A logo or a brand name set while the preset is not `Branded` used to be ignored and is now shown. A `Branded` page with neither set used to show no brand row and now shows the product's own mark. Under `Minimal` the brand row is absent from the markup, so a custom stylesheet or script that looked for it finds nothing. ## Footer block of the sign-in page `FooterHtml` puts markup of your own at the bottom of the sign-in card — links to a privacy policy and terms of service, a short legal note, a support contact. The block is shown in **every** preset, `Minimal` and a custom stylesheet included, and on both pages that render the sign-in window: the sign-in itself and the page that opens a confirmation. No other page Veriqa generates carries it — the confirmation pages, the expired-link page and the email sign-in pages have no footer block. ```json appsettings.json theme={null} { "Veriqa": { "AuthPageDesign": { "FooterHtml": "Privacy · Terms" } } } ``` The block is set in this global section and by a client entry (`Veriqa:OpenIddict:Clients`), where an application sets a block of its own instead of the global one. A `ui_config` record does **not** set it: a record is chosen by a request parameter, and what a request may choose is the look of the page, not markup on it. **The `Tenant` level.** The setting exists at the tenant level as well, but no reader for that level is registered out of the box — the integrator writes one. Without it the resolution goes to the client entry and to this section. **A blank value sets nothing**, at every level alike. So an application whose entry carries an empty `FooterHtml` shows the block of the global section: a lower level cannot switch off the block an upper one sets, it can only replace it. To leave one application without a block, set the block in the entries of the applications that need it rather than globally. ### What the markup may contain The value is checked against a strict shape and is refused **whole** when it steps outside it — nothing is stripped, nothing is repaired: * **text** — any characters except `<` and `>`. A bare `&` is fine; a bare `>` is not, write it as `>`; * **tags** `a`, `span`, `div`, `p`, `strong`, `em`, and the void `br` (`
`, `
`, `
` — there is no `
`). Names of tags and attributes are case-insensitive; * **attributes** `href`, `target`, `rel` and `class`, each at most once per tag, with the value in double quotes and free of `"`, `<`, `>`. Anything else — `style`, `id`, any `on*` handler — is refused, and so are single-quoted and unquoted values; * **no class name of the page itself**: a `class` is refused when one of its names is `veriqa` or begins with `veriqa-`, in any letter case and after character references are decoded. A name that merely starts with the word — `veriqable` — is fine, and so is an empty `class`; * **every link carries `href` and `target="_blank"`.** The address is judged in the form the browser reads it — after character references are decoded — and must be `https://…`, `http://…` or a root-relative `/path`. A protocol-relative `//host`, a `javascript:` or `mailto:` address, a backslash or a control character inside the address are refused; * **the tags are balanced**: a closing tag closes the innermost open one, and none stays open at the end; * comments, ` **The block is emitted as is — it is not sanitized.** The check above decides whether a value may go into the page; it does not clean it. What the block says, where its links lead and what they do to the person who follows them are yours to answer for. Veriqa adds no `rel` of its own either: the attributes of your links are yours to set. **Why `veriqa-` class names are refused.** They belong to the markup of the page: its script reaches its own elements by class (`.veriqa-tab`, `.veriqa-panel`, `.veriqa-tooltip-host`, `.veriqa-tooltip`), so a block carrying one of them would affect the behavior of the page it sits on — a `class="veriqa-panel"` inside the block would be hidden by the channel tabs. Name your own classes with a prefix of your own and style them from your stylesheet (`CustomCssPath`). **Upgrading with such a class already set.** Earlier versions accepted a `veriqa-` class in this block; now the value is invalid. Set in this global section, it stops the host from starting; set in a client entry, it drops that entry — see the table below. Rename the class before you upgrade. ### What an invalid value does | Where the value is set | What happens | | - | - | | This global section | The host **does not start**, and the message names the setting and the section to correct it in. A configuration reloaded into an already running host keeps the value last read successfully instead | | A client entry | The entry is dropped from the effective configuration **whole** — that client then resolves every one of its settings, not merely the block, from the level below. The log names the client and the setting | | A translation in a locale file | The page renders **without** the block, and a warning naming the setting and the language goes into the log. The value stays in force for the other languages | ### Translating the block The value is a Natural Key like every other text of the page (see [Texts set in configuration](#texts-set-in-configuration-are-localization-keys-too)): the page looks it up in the host locale files, and a value no file carries is emitted as is. The translation is checked against the same shape as the value — a translation that steps outside it leaves the page without the block. The context suffix applies too, and with markup it is worth using: the block is a long string, and a label keeps it from colliding with anything. ```json appsettings.json theme={null} { "Veriqa": { "AuthPageDesign": { "FooterHtml": "Privacy | authpage" } } } ``` ```json ru.json theme={null} { "Privacy | authpage": "Конфиденциальность" } ``` Note that `|` is **reserved** for that label: everything after the **last** `|` is cut off the text the page shows. Markup that has to contain `|` of its own does not fit this convention — use a `|` entity or another separator. ## Markup in the title, the instruction and a channel hint Three texts of the sign-in window take inline markup of your own — a word in bold, a link, a `span` with a class of yours: * **the title** — the `Title` of a `ui_config` record, or your wording of the built-in title in `AuthPage.Texts` or in a locale file; * **the instruction under it** — the `Instruction` of a record, or your wording of a built-in [instruction](#the-instruction-follows-both-settings); * **a channel hint** — an entry of `Veriqa:ChannelDisplay:Hints`. ```json appsettings.json theme={null} { "Veriqa": { "UiConfigurations": { "Records": { "brandA": { "Title": "Sign in to Acme", "Instruction": "Confirm the sign-in to Acme" } } }, "ChannelDisplay": { "Hints": { "whatsapp": "Send the message as it is" } } } } ``` The value passes the same check as the [footer block](#what-the-markup-may-contain), with a narrower set of tags — inline ones only: * **tags** `span`, `strong`, `em`, `a` and the void `br`. `div` and `p` are refused here: the text already sits inside a heading or a paragraph; * **attributes** `href`, `target`, `rel` and `class`, under the rules of the footer block — double quotes, at most once per tag, nothing else; * **every link carries `href` and `target="_blank"`**, for the same reason: a link that opens in place ends the sign-in running on the page; * **no class name of the page itself** — `veriqa` and anything beginning with `veriqa-` are refused; * **no `img` and no `svg`.** An icon is a `span` with a class of yours, drawn by your own stylesheet (`CustomCssPath`): ``. Attaching a stylesheet switches the design preset off — see [External CSS](#external-css). **A value that fails the check is shown as plain text.** Unlike the footer block, nothing stops the host and no entry is dropped: the value is judged when the page renders, in the language it renders in — a translation from a locale file is judged as translated. A value that steps outside the shape goes onto the page **whole, as text** — the tags are visible as characters — and a warning goes into the log. The warning names the element and the language (for a hint — the channel type); the value itself is not logged. **Plain text is read as HTML text.** A value with no tags passes as is, and the browser reads its character references: `Terms & Conditions` shows "Terms & Conditions". Write a literal `<` or `>` as `<` and `>` — a bare one fails the check, and the value is then shown as written, with the warning above. **The browser tab gets the title without its tags.** The `` of the document carries the text of the title alone, with a `<br>` turned into a space. The same holds on both pages that render the window — the sign-in itself and the page that opens a confirmation. A renderer of your own receives these values raw — see [Sign-in page renderer](#sign-in-page-renderer). ## Instruction after the scan In [hop mode](/docs/reference/configuration#veriqahopmode) the page learns that the user has continued on the phone before the confirmation arrives: the QR code was scanned, the page opened on the phone, the transition into the messenger started — the phone page starts it by itself, and its button starts it again on a tap. That last step says the browser began to open the app, not that the app opened. The status line names each such step. The scan names what follows on the phone: "QR code scanned. Continue in Telegram." for a messenger, "QR code scanned. Continue on your phone to confirm by email." for Email, and "QR code scanned. Continue on your phone." for the QR code shared by every channel. When the phone page opens the app by itself, the last two steps arrive together, and the status line shows only "Opening …" instead of flashing both. On the first of them the page takes the channel choice away — the tabs, the QR codes and the channel buttons, with the instruction under the title that describes them — and shows an instruction in their place: by default "Continue on your phone and confirm in the app that has opened." The subject of a confirmation, the status line and the countdown stay. Below the instruction is the "Choose another way" button: it brings the channel choice back on the same page, with the same sign-in — for a user who scanned with the wrong device or changed their mind. The next step reported from a phone, the same phone included, shows the instruction again. Once a channel has answered the start — its bot sent the question into the chat — the button disappears from the page for good: the sign-in is bound to that channel's account from then on, and another channel could only end in a refusal. On a sign-in, where the status line then says "You have moved to …", the instruction disappears together with the channel choice: the page keeps the title, the status line, the countdown and "Cancel sign-in". A page that fell back to polling does not hear the answer and keeps the button. Reloading the page brings the channel choice back as well; once the sign-in expires, the instruction disappears and the restart link appears. `HopInstructionHtml` replaces the core's text with markup of your own: ```json appsettings.json theme={null} { "Veriqa": { "AuthPageDesign": { "HopInstructionHtml": "<p>Check your phone: <strong>confirm in the messenger</strong> that has just opened.</p>" } } } ``` The setting follows the [footer block](#footer-block-of-the-sign-in-page) in every rule but one: the same levels (this global section, a client entry, the tenant — not a `ui_config` record), a blank value sets nothing, the same allowed shape and the same outcome of an invalid value, and the value is a Natural Key. The exception is a translation outside the shape: the page then shows the core's own text rather than no block, because the block sits where the channel choice was, and an empty one would leave the user without a word on what to do next. A warning naming the setting and the language goes into the log. The "Choose another way" button is not part of the setting: it sits beside your markup, not inside it, and its caption is a text of the sign-in window like any other. The setting has no style of its own. Style the block from your stylesheet (`CustomCssPath`) through its `veriqa-hop-instruction` class; the page's own rules set only its text color, size and spacing. ## CSS variables The pages are styled through CSS variables prefixed `--veriqa-*`, which you can override in your own CSS. The main names are below; the full set is declared in the package stylesheet and also includes the `--veriqa-spacing-*` scale and the colors of the service-page badges: ```css theme={null} :root { /* Primary — neutral canon per theme (light: #111827, dark: #f9fafb); the client color applies only in the Branded preset */ --veriqa-primary: #111827; --veriqa-on-primary: #ffffff; /* Surfaces and background (light theme) */ --veriqa-bg: #ffffff; --veriqa-surface: #f9f9fa; --veriqa-surface-variant: #e2e2e3; --veriqa-surface-container: #eeeeef; /* Text */ --veriqa-text: #1a1c1d; --veriqa-muted: #4c4546; --veriqa-soft: #767071; /* Semantic colors */ --veriqa-success: #16a34a; --veriqa-error: #dc2626; --veriqa-warning: #eab308; /* Radii, typography, animation */ --veriqa-radius: 8px; --veriqa-radius-lg: 12px; --veriqa-font-family: system-ui, -apple-system, "Segoe UI", Roboto, Arial, sans-serif; --veriqa-font-mono: "JetBrains Mono", Consolas, monospace; --veriqa-transition-fast: 160ms ease; } ``` ## External CSS Attach your own stylesheet via `CustomCssPath` — it loads **after** the base styles and can override any variable. When `CustomCssPath` is set, the preset is ignored: your CSS fully defines the look. The stylesheet is attached to **every** page Veriqa generates: the sign-in window, the sign-in confirmation page, the expired-link page and the email sign-in pages. Your CSS styles them all alike — there is no separate setting for the service pages. The expired-link page always offers one action. When the application sets `InitiateLoginUri` — its own address for starting a sign-in (the `Client.InitiateLoginUri` key of the [configuration reference](/docs/reference/configuration)) — the page links to it as "Start the sign-in again"; the address must be an absolute `https` URL. Without one the page shows a "Close" button instead. The texts of this page can be replaced like the texts of the sign-in window — see [Rewording a page element for one application](#rewording-a-page-element-for-one-application). The page belongs to a sign-in only: a link of the sign-in flow that names an action confirmation leads to the neutral "This link is no longer available" page instead. ```json appsettings.json theme={null} { "Veriqa": { "AuthPageDesign": { "CustomCssPath": "/css/veriqa-custom.css", "CssSriHash": "sha384-..." } } } ``` Set `CssSriHash` (Subresource Integrity) if styles load from an external address — external resources on the sign-in page are disallowed by default. ## Per-client UI configurations (ui\_config) Beyond the global look, you can define **named UI configurations** and bind them to a specific OIDC client. The catalog of records is the `Records` map of the `Veriqa:UiConfigurations` section ("record code → look"), so the full path of a record is `Veriqa:UiConfigurations:Records:<code>`; a record overrides the global look for its code. ```json appsettings.json theme={null} { "Veriqa": { "UiConfigurations": { "Records": { "brandA": { "Preset": "Branded", "PrimaryColor": "#FF5500", "LogoUrl": "https://your-domain.com/logo.png", "BrandName": "Acme", "Title": "Sign in", "TtlSeconds": 300 } } }, "OpenIddict": { "Clients": [ { "ClientId": "my-app", "DefaultUiConfig": "brandA", "AllowedUiConfigs": [ "brandA" ] } ] } } } ``` * `DefaultUiConfig` — the record code applied by default for the client. * `AllowedUiConfigs` — codes the client is allowed to request. A code from this list is valid even without a catalog record — the global look is then used. ## Localization The sign-in page uses the **Natural Keys** approach: the localization key is the English source text, and `en` is the base language (it needs no translation file). The supported-language registry is data-driven and built from the locale files **your host** ships: JSON dictionaries `{lang}.json` in `wwwroot/locales` (the path is configurable via `Veriqa:Channels:Localization:LocalesPath`) plus the base `en`. Out of the box the page renders in English; to add a language, put its `{lang}.json` into the locales directory — no code changes required. You do not have to translate Russian and Chinese at all: they are shipped, see [Ready-made translations](#ready-made-translations) below. The directory itself is optional: a host without it logs `Locale files directory not found — channel messages are sent in the base language.` on startup, and that warning is expected. ### Ready-made translations The whole catalog — 106 keys, everything a user reads while authenticating — is published as a content-only package, `Veriqa.Core.ChannelLocales`. It carries `en.json`, `ru.json`, `zh.json` and `pseudo.json` (a layout-test locale rather than a production language: it is detectable through an explicit `Accept-Language`, but refused as `DefaultLanguage`). ```xml PackageReference theme={null} <PackageReference Include="Veriqa.Core.ChannelLocales" Version="0.6.0" /> ``` <Warning> **The package by itself switches no language on.** Its build targets lay the files down under `veriqa-channel-locales/` beside your entry assembly, deliberately **not** in `wwwroot/locales`: the core catalog must never overwrite yours — the two are meant to be compared, not merged. Until one of the two steps below is taken, the host still renders in the base language. </Warning> Two ways to use it: * **copy** the files you need out of `veriqa-channel-locales/` into your own locales directory. They are yours from then on, and you are free to edit any wording in them; * **point at it**: set `Veriqa:Channels:Localization:LocalesPath` to the directory the package lays down. A relative path is resolved from the host's **content root**, so set a path that is right for the way you run the application — or an absolute one. Nothing there is yours to edit: the next version of the package replaces those files whole. The package is also the key list for a host that keeps a catalog of its own: `en.json` is the authoritative set of keys, and a key missing from your files quietly falls back to English for the user. ### Which language a user gets The sign-in page and the messages in the channel take their language from **different** sources, and the two legitimately differ. **The sign-in page** follows the request: the OIDC `ui_locales` parameter first — an explicit choice made on the relying party's side — and the browser's `Accept-Language` after it. A value matching no language of the registry falls back to the default language: ```json appsettings.json theme={null} { "Veriqa": { "Localization": { "DefaultLanguage": "en" } } } ``` `DefaultLanguage` is validated at startup: the value must be in the registry (the host's locale files + `en`), otherwise the application does not start. For example, `"DefaultLanguage": "ru"` requires the host to ship `wwwroot/locales/ru.json`. The language the page was rendered in travels **on the transaction**, and every later page and message of that sign-in falls back to it — including the `form_post` auto-posting page that delivers the authorization response, which renders in that language instead of reading the browser header again. `Accept-Language` with the default language stays the fallback for responses that never had a transaction, such as errors returned to the `redirect_uri`. A confirmation created [server to server](/docs/reference/confirmation-api) has no page at all — what travels there is the `locale` field of the request. **A message inside a channel** follows the recipient instead. A channel that knows the user's profile language supplies it with every event, and it outranks everything the browser said: a user whose Telegram is in Russian gets a Russian prompt from a page rendered in English. A channel that supplies nothing falls back to the locale the transaction carries, and failing that to the base language. | Channel | Supplies the recipient's language | | - | - | | Telegram | yes — the language Telegram reports for the user | | MAX | yes — the `user_locale` of the update | | WhatsApp | no — the transaction locale is used | | Email | no — see below | **Email is a third case**, because its texts are not read inside a messenger. The sign-in email goes out before anyone has answered, so there is no profile to ask: it is worded in the language of the request that asked for the email — that browser's `Accept-Language` — falling back to the locale of the transaction. The confirmation page the magic link opens does the opposite: it prefers the transaction's locale over the `Accept-Language` of the mail client's browser, so that page matches the sign-in window the user started in. One installation may therefore speak two languages in one sign-in, and that is the intended behavior rather than a misconfiguration. What it does require is that every language involved is present in the locale files: a language nobody translated degrades to the base one silently. ### Channel and email texts — the same locale files The same directory drives more than the sign-in page: nearly everything the user sees **in the messenger and in the email** — button captions, the status after their answer, the visible lines of the email — comes from there. One rule: the key is the English text, the value is what the user sees. To reword anything, add the key with your value to the locale file of that language — no code involved. The main keys: | Key (also the English text) | Where it shows | | - | - | | `Confirm ✅` · `Decline ❌` | buttons under the confirmation request (Telegram, WhatsApp, MAX) | | `Sign-in confirmed ✅` · `Sign-in declined ❌` | the status after the user answers **when signing in**, in the same channels | | `Sign-in confirmed ✅ Tap Back to return to the sign-in page.` | the confirmed status in the same channels when the sign-in was **started on a phone or a tablet**: the channel opened on that same device, and the text tells the user how to get back | | `Sign-in confirmed` · `Sign-in declined` · `Sign-in confirmed. Tap Back to return to the sign-in page.` | the same three for third-party channels when signing in — a platform need not render emoji | | `Something went wrong ⚠️ Go back to the sign-in page and start again.` · `Something went wrong. Go back to the sign-in page and start again.` | an error while handling the answer — the channel's own text, independent of the transaction type (the emoji variant in the same channels, the plain one in third-party channels) | | `Too many attempts. Wait a minute and start the sign-in again.` | the answer when the per-user attempt budget is spent — it advises waiting rather than starting over, which a spent budget would refuse again | | `Sign-in time expired ⌛ Go back to the sign-in page and start again.` · `Sign-in time expired. Go back to the sign-in page and start again.` · `Sign-in time expired. Start the sign-in again from the application.` | the status **when signing in**, once the confirmation window has passed — in the same channels, in third-party channels and on the Email confirm page respectively | | `Sign-in time expired ⌛` | the same status on the pages Veriqa itself serves | | `Confirmed ✅` · `Declined ❌` · `Time expired ⌛` | the receipt of a confirmed action, not of a sign-in; the text is neutral and the same on every surface | | `Confirm sign-in` | the confirmation request without initiator context | | `Confirm sign-in to "{app}"` · `… — request from {browser} on {os}` · `… on {os}, {region}` | the request with initiator context; a translation must keep the `{slot}` set — on a mismatch the English text is used | | `Sign in to {app}` | the subject of the Email channel's email | | `Confirm sign-in` · `You requested sign-in via Veriqa.` · `The request came from:` · `Press the button below to confirm sign-in.` · `The link is valid until:` · `Approve sign-in` · `To confirm, open the following link:` · `Or scan the QR code to finish signing in on another device:` · `Direct link:` · `Do not forward this mail: the link grants sign-in access to your account.` · `If you did not request sign-in, simply ignore this mail.` | every visible line of the sign-in email: the heading, the intro, the label of the initiator details, the hint above the button, the label of the validity time, the button caption, the hint above the plain link, the QR code block captions, the footer. `Confirm sign-in` is the same key the confirmation request uses | <Note> **Which of the two confirmed texts a user gets is decided by the message template, not by the locale file.** The receipt of a sign-in is the message kind `outcome-receipt-confirmed`, and its template narrows by the device the sign-in was started on — the `ByDevice` group, with the values `desktop`, `mobile`, `tablet` and `unknown`. The product ships the short text for every device and the text with the way back under `ByDevice:mobile` and `ByDevice:tablet`. The locale file only translates each of them. If you set the receipt of a sign-in in the configuration of the host without `ByDevice`, phones and tablets still get the shipped text with the way back — set your own `ByDevice` steps as well. A wording set for one application, tenant or `ui_config` record answers for every device by itself. The addresses and an example: [Configuration reference](/docs/reference/configuration#veriqamessagetemplates). </Note> <Note> These are **installation-wide product defaults**: locale files are a host asset, one set per process. Per-tenant wording is not configured here: the texts of the pages Veriqa renders are reworded for one application or tenant by [a dictionary of its own](#rewording-a-page-element-for-one-application). </Note> <Note> **Every visible phrase of the shipped sign-in email is a localization key.** The body of the email — both the HTML and the plain-text part — holds no wording of its own: each phrase is a localized-text slot, so an email in the recipient's language is in that language throughout, and translating the email is editing a locale file. A phrase does not carry a substitution inside it: the value sits beside its label — `The link is valid until:` followed by the time, `The request came from:` followed by the browser, the system and the region — and the name of the application has a line of its own under the heading. The subject `Sign in to {app}` is the one text translated as a whole. The same holds for the claims-completion emails, `claim-code-link-mail` included. A translated phrase is limited to 128 characters, like any value that comes from outside the product; a longer one is cut. Values Veriqa computes itself — the link, the code, the validity time — are substituted in full, whatever their length. If you [set your own email body](/docs/guides/channels#your-own-mail-body), split a long sentence into several slots rather than one. </Note> ### One phrase, two translations: the context suffix The key IS the English text, so one phrase yields one translation per language. When the same English phrase must translate **differently** in different places (one channel worded unlike the rest, say), the key carries a context label after `|`: ```json ru.json theme={null} { "Sign-in declined ❌": "Вход отклонён ❌", "Sign-in declined ❌ | telegram": "Вход в Telegram отклонён ❌" } ``` What to know: * **the label is metadata — the user never sees it.** With no translation available, the part of the key **before the last** `|` is shown, i.e. the clean English text, not `… | telegram`; * **never map a key to itself** (`"X | telegram": "X | telegram"`) — that is the one way to put the label in front of a user; * **`|` is reserved** for the label: ordinary UI text must not contain it; * **add a context only on a real divergence.** By default one phrase is one key with no suffix; there is no value in contexts whose translations coincide. ### Texts set in configuration are localization keys too The texts a `ui_config` record sets go through the **same** resolution as the built-in ones: the configured value IS a Natural Key. There is no such thing as an unlocalizable text in the product. | `ui_config` setting | What it sets | | - | - | | `Title` | the sign-in window title | | `Instruction` | the on-page instruction of the sign-in window | | `BrandName` | the brand name in the brand row of the sign-in window, in every preset but `Minimal` | <Note> **The confirmation text is set by a message template, not by a field of the record.** The wording of the confirmation request is a **message**, and it is set by the ladder of its template: `Veriqa:MessageTemplates:confirmation-prompt:Templates` (how the key is built and which levels it has — in the [Configuration reference](/docs/reference/configuration#veriqamessagetemplates)). The rule of this section applies to it in full: the text you set is a Natural Key just the same and does not bypass localization. If one application needs a wording of its own, set it in that client's entry (the `Application` level) rather than globally. </Note> What this means in practice: * **a value with no locale entry is shown as is.** The key IS the text, so a miss breaks nothing: the user sees exactly the string you set, merely untranslated. The base language needs no entry at all; * **to have the text translated, add its key to your locale files** — the same `wwwroot/locales/{lang}.json` as every other text. The locales directory is a host asset, and the host is you: the keys of your `ui_config` records go in there alongside the product ones. **How to set a value that is meant to be translated.** There is no separate marker for it — use the regular context suffix (above): the setting holds a `<text> | <label>` string and the translation goes into the locale files. ```json appsettings.json theme={null} { "Veriqa": { "UiConfigurations": { "Records": { "brandA": { "Title": "Sign in to the portal | brandA", "Instruction": "Choose a channel | brandA" } } } } } ``` ```json ru.json theme={null} { "Sign in to the portal | brandA": "Вход в портал", "Choose a channel | brandA": "Выберите канал" } ``` What to know: * **the label is metadata**: everything after the **last** `|` is invisible to the user. The value `Privacy | Terms` renders as `Privacy` — a setting whose own text has to contain `|` does not fit this convention; * **a miss is not a failure**: with no locale entry the user sees `Sign in to the portal` — the right text, merely untranslated. No label, no blank, no error; * **you choose the base text**: the value `Политика | authpage` with no English translation shows an English-speaking user Russian. The behavior is predictable, but the base text is best written in the base language (`en`); * **the label is not always needed.** A text of yours that is already unique can be translated with no suffix — the value itself is the key. The label guards against colliding with a product key and separates one phrase across different `ui_config` records. <Warning> A configured value is looked up in the locale files. If it **happens to coincide** with the key of a product text — say, `Title` set exactly to the built-in sign-in window title — non-English languages get the product translation instead of your string. If such a text must stay yours, append a context suffix to the value: the key stops colliding with the product one, and the user still sees the same string up to the last `|`. </Warning> ### Rewording a page element for one application A locale file rewords a text for the **whole installation**. When one application needs its own wording of a built-in element — a feedback form that confirms a message rather than signs anyone in, say, and wants "Confirm in WhatsApp" instead of "Open in WhatsApp" — set it in the `AuthPage.Texts` dictionary of that application. An entry is keyed by the **built-in text** of the element — its English text, the same string that keys it in the locale files, spelled as in the list below — and specifies what to show in its place. ```json appsettings.json theme={null} { "Veriqa": { "OpenIddict": { "Clients": [ { "ClientId": "site-feedback", "AuthPage": { "Texts": { "Open in WhatsApp": "Confirm in WhatsApp | feedback", "Sign in via Email": { "en": "Confirm via Email", "ru": "Подтвердить через Email" } } } } ] } } } ``` ```json ru.json theme={null} { "Confirm in WhatsApp | feedback": "Подтвердить в WhatsApp" } ``` **Levels.** The dictionary exists at three levels: the tenant (`AuthPage:Texts` in its record), the application (`AuthPage:Texts` in its entry of `Veriqa:OpenIddict:Clients`) and the `ui_config` record (its `texts` member; in `appsettings.json` — `Veriqa:UiConfigurations:Records:<code>:Texts`). There is no installation-wide level: that is what the locale files are for. The levels merge **entry by entry**: for each text the most specific level that sets an entry wins, and the entries a lower level sets for other texts stay in force. So a tenant may reword one button and an application another, and both show. <Note> **The `Tenant` level.** As with the [footer block](#footer-block-of-the-sign-in-page), no reader for the tenant record is registered out of the box — the integrator writes one. Without it the dictionary comes from the client entry and the `ui_config` record. </Note> **Two forms of an entry:** * **a string is a replacement key.** It goes through the locale files exactly like a text [set in configuration](#texts-set-in-configuration-are-localization-keys-too): the page looks the replacement up in the locale file of its language, and with no translation shows it as is. The context suffix `|` is allowed and advisable — above, `feedback` keeps the replacement from colliding with a product key; * **an object holds ready-made texts by language.** They bypass the locale files: the page shows the text of the language it renders in as is. Name the languages the way your locale files are named (`en`, `ru`, `zh`); the page renders in the language of the request when your locale files translate it, and in English otherwise. A language the object does not name gets the **regular translation of the built-in text**, not another language of the object — in the example above a user reading Chinese gets the Chinese translation of "Sign in via Email" from your locale files. A blank string, an empty object and an object of blank texts set nothing, and the entry then comes from the level below. **Slots must match.** Some built-in texts carry values the page fills in: `Link sent to {0}. Check your inbox.`, `Confirm the address {address} to sign in to {app}.` A replacement — the string, or each text of the object — has to carry **exactly** the same slots. One that drops or adds a slot is not applied: the page shows the built-in text the regular way. **Which texts are open.** The dictionary reaches the pages Veriqa renders for the user during a sign-in or a confirmation: * the sign-in window — on both pages that render it, the sign-in itself and the page that opens a confirmation — including the status lines its script shows; * the launch page of [hop mode](/docs/reference/configuration#veriqahopmode) on the phone (its heading "Sign in via …", its instruction, the status line once the transition has started and the line that sends the user back to the sign-in window) and the lines the window shows on a phone that opened a sign-in by its QR code — the outcome and the way back to the sign-in window; * the email pages: the page the magic link opens — the request of a sign-in and the line it turns into after "Continue signing in", the "Continue" page of a confirmation and the line of the fast link — the page that opens the mail app for a sign-in by email, the form that requests a sign-in link, and the page of the link that confirms an email address during claims completion. <Accordion title="The built-in texts an entry may name"> **Sign-in window:** `Sign in via trusted channel` · `Scan the QR code or tap the button to sign in via {0}` · `Scan the QR code to sign in via {0}` · `Tap the button to sign in via {0}` · `Choose a channel and scan the QR code or tap the button` · `Choose a channel and scan the QR code` · `Choose a channel and tap the button` · `Scan the QR code or tap the channel button` · `Scan the QR code to sign in` · `Tap the channel button to sign in` · `Scan the QR code you need or tap a messenger button` · `Sign-in QR code` · `Any channel` · `{0} ▦` · `Open in Telegram` · `Open in WhatsApp` · `Open in Instagram` · `Open in Messenger` · `Open in WeChat` · `Open in Viber` · `Open in MAX` · `Sign in via Email` · `Send confirmation email` · `Sign in via email link` · `OR` · `← Back to QR code` · `← Back` · `Email address` · `Get a link` · `Send again` · `Link sent to {0}. Check your inbox.` · `Failed to send the link. Please try again.` · `Email link opened. Confirm the sign-in.` · `The confirmation email is open in your mail app. Send it to continue.` · `If you have already sent the email, wait for the confirmation. It usually takes a few seconds.` · `Email received. Verifying the sender…` · `Sender verified. Completing sign-in…` · `Sign-in has already started in another channel.` · `Confirmation has already started in another channel.` · `QR code scanned. Continue on your phone.` · `QR code scanned. Continue on your phone to confirm by email.` · `QR code scanned. Continue in {0}.` · `The page is open on your phone.` · `Opening {0}…` · `Continue on your phone and confirm in the app that has opened.` · `Choose another way` · `Back to channel selection` · `On iPhone, a browser page with two buttons may open first. Tap the one that opens Messenger, then confirm that you want to open the Messenger app.` · `Problem signing in` · `This QR code was also scanned on another device. If that wasn't you, start over.` · `Complete the form on your phone` · `Waiting for confirmation…` · `Confirmed. Processing…` · `Sign-in successful!` · `This sign-in request has expired` · `Start the sign-in again` · `Cancel sign-in` · `Authentication error` · `Send the code in the chat with the bot {0} if the bot has not sent you a confirmation` · `Send the code in the chat with the bot if the bot has not sent you a confirmation` · `Copy and open {0}` · `Copy` · `Copied` · `You have moved to {0}. Tap the confirmation button in the chat with {1} if you want to continue signing in, or tap cancel.` · `You have moved to {0}. Tap the confirmation button in the chat if you want to continue signing in, or tap cancel.` (the last two are the status line once the bot of a channel has answered the start of a sign-in) **Expired sign-in session page:** `Sign-in session expired` · `This sign-in link has expired or was already used. Please start the sign-in again from the application.` · `Close` · `You can close this tab.` (its link is `Start the sign-in again`, listed above) **Phone pages of a scanned sign-in:** `Sign in via {0}` · `Continue in {0}` · `The app should open by itself. If it does not, tap the button below.` · `The {0} app should open by itself. If it does not, tap the button below.` · `A browser page with two buttons will open. Tap the one that opens Messenger, then confirm that you want to open the Messenger app. After that the chat with the bot will open. If nothing opens, tap the button below.` · `You have moved to {0}. Tap the confirmation button in the chat with {1} if you want to continue signing in, or tap cancel.` · `You have moved to {0}. Tap the confirmation button in the chat if you want to continue signing in, or tap cancel.` · (the two lines above are shared with the sign-in window, and so are `Problem signing in` and `Copy and open {0}`, the link and the button of the spare start code, and `Continue in {0}`, the heading of the sign-in window a phone opens by a QR code of several channels once a channel is tapped) · `Sign-in confirmed. Continue on the device where you started.` · `This sign-in is no longer active here. Check the device where you started.` · `Return to the sign-in window and confirm the sign-in there.` (shared with the email pages: an entry changes it on the phone pages and on the page of the magic link alike) **Email pages:** `Sign-in request` · `A sign-in to {app} was requested. If it was not you, close this page.` · `Continue signing in` · `Return to the sign-in window and confirm the sign-in there.` · `Cancel` · `Approve sign-in` · `Continue` · `Sign-in confirmed. Continue on the device where you started.` · `The link has expired. Request a new one.` · `The link has already been used. Request a new one.` · `The link is invalid or has expired. Request a new one.` · `Something went wrong. Try requesting a new link.` · `Confirmation required` · `Continue to see what you are asked to confirm.` · `The sign-in link has been sent to {email_masked}` · `Send again` · `Failed to send the link. Check the address and try again.` · `The Email adapter Pull mode is disabled.` · `Sign in via email link` · `Sign in with a link` · `Enter your email — we will send you a sign-in link.` · `Session not found. Return to the sign-in page.` · `Email address` · `Get a link` · `Confirm your email address` · `Confirm the address {address} to sign in to {app}.` · `If you did not sign in to {app}, do not confirm the address.` · `Confirm the address` · `The address is confirmed. Return to the sign-in page.` · `This link no longer works. Request a new one on the sign-in page.` · `Close` · `You can close this tab.` </Accordion> The rest keeps its built-in texts: the service pages (the response page that posts the result back to your application and the unavailable-link page), the confirmation pages outside the sign-in window, the claims completion form, the address placeholder `your@email.com`, and the emails — an email is worded by its [message template](/docs/guides/channels#your-own-mail-body). The caption of a third-party channel's button is the name the channel reports rather than a built-in text, so the dictionary does not reach it either. **One text is one element everywhere.** An entry changes the text wherever an open page shows it: `Open in Telegram` is the button of the sign-in window and of the launch page on the phone, and `Sign in via email link` is both the tab of the window and the title of the link request form. On a page that is not open the same text stays built-in — an entry for `Confirmation required` changes the "Continue" page of the email link and the error page that link answers with, not the confirmation page that carries the same heading. **A dedicated setting outranks the dictionary.** Where a text is set by its own setting — `Title`, `Instruction`, `BrandName`, `FooterHtml`, `HopInstructionHtml` — the page shows that text, and an entry for the built-in text it replaces does not apply. The dictionary acts only where the page falls back to its built-in text. **An entry that never applies is reported, once.** An entry for a text outside the list above, and a replacement whose slots do not match, go into the configuration snapshot report with a warning naming the level, the record and the configuration key of the entry — at startup and again when the configuration source reloads, not on every page. The report is also where a reworded built-in text shows up: the key is the English text, so a text the product rewords gets a new key, and an entry for the old one finds nothing. After an update, check the report and move such entries — and the values of your locale files — to the new keys. Among the recently reworded texts: the button of the one-tap email (`Send confirmation email`), the status line after it (`The confirmation email is open in your mail app. Send it to continue.`), the line of a scan (`QR code scanned. Continue on your phone.` and its variants listed above) and the message shown when the claims completion form hits the per-address email limit. The page of the magic link no longer asks `Do you confirm the sign-in?` with `Yes` and `No`, and the launch page on the phone no longer has a separate button that only copies the spare start code. The host starts regardless and every other entry of that client applies. If the dictionary cannot be resolved at all (the `ui_config` store is unreachable), the page renders with its built-in texts and a warning goes into the log. ## Extension points Key services can be swapped for your own implementation — register them in the `AddVeriqaAuthServer` block. ### Sign-in page renderer Implement `IAuthPageRenderer` for full control over the auth page HTML: ```csharp theme={null} public sealed class MyAuthPageRenderer : IAuthPageRenderer { public string RenderAuthPage(AuthPageRenderContext context) => "<html>...</html>"; } // Registration authServer.UseAuthPageRenderer<MyAuthPageRenderer>(); ``` `AuthPageRenderContext` arrives with values that are **already resolved**: `Design` is the effective page design (the global section overridden field by field by the ownership levels), `Title` and `Instruction` are the texts if some level set them (otherwise `null`, and the text is yours), and `QrVisibility` is the effective QR visibility. Your renderer combines nothing on its own and therefore cannot lose an override. Three more members arrive resolved the same way, and a renderer of your own has to act on each — the built-in one does: * `ChannelButtonVisibility` — the visibility of the channel buttons, already reconciled with `QrVisibility`: a pair that would leave a device with neither arrives as `Always`; * `ChannelLayout` — `Tabs` or `Grid`; * `CancelForm` — the form behind "Cancel sign-in", or `null` when this page offers no such button. Render it as a `POST` form to `PathBase` + `CancelForm.ActionUrl` with two hidden fields: `session_id` carrying `SessionId`, and `CancelForm.AntiforgeryFieldName` carrying `CancelForm.AntiforgeryToken`. `Title` and `Instruction` arrive as **localization keys**, not as finished text (see "Texts set in configuration are localization keys too"). The built-in renderer runs them through the locale files; your renderer does the same by calling `IConfirmationPromptLocalizer.ResolveOrBaseText(value, language)` — otherwise a text set at a level stops being translated, and a value with a context suffix shows its label. They also arrive **raw**, and so does the `Hint` of a channel: the value may carry [markup](#markup-in-the-title-the-instruction-and-a-channel-hint), and nothing has checked it. The built-in renderer checks each value and falls back to plain text; a renderer of your own either HTML-encodes the value before it goes into the page or checks it by rules of its own. Emitting it as is puts whatever the setting holds into your page. ### Claims mapper Implement `IClaimsMapper` for your own mapping of the resolved identity to OIDC claims: ```csharp theme={null} public sealed class MyClaimsMapper(IMyDirectory directory) : IClaimsMapper { public async Task<Result<IReadOnlyList<Claim>>> MapToClaimsAsync( ResolvedIdentitySnapshot resolvedIdentity, CompletionSnapshot completion, ClaimsMappingContext context, CancellationToken cancellationToken = default) { var claims = new List<Claim> { new(ClaimTypes.NameIdentifier, resolvedIdentity.Subject) }; // Extra channel claims live in the resolvedIdentity.Claims dictionary if (resolvedIdentity.Claims?.TryGetValue("name", out var name) is true) { claims.Add(new(ClaimTypes.Name, name)); } // The mapping runs on the request thread, so enriching the claims from your own // database or directory is a plain await — no sync-over-async is needed var profile = await directory.LoadAsync(resolvedIdentity.Subject, cancellationToken); claims.Add(new(ClaimTypes.Role, profile.Role)); return Result<IReadOnlyList<Claim>>.Success(claims.AsReadOnly()); } } // Registration authServer.UseClaimsMapper<MyClaimsMapper>(); ``` `UseClaimsMapper<T>()` registers the mapper as **scoped** — one instance per authorization request. That is what lets it take a scoped dependency (`DbContext`, a unit of work, any per-request service) straight through the constructor, as `IMyDirectory` above: no `IServiceScopeFactory` dance is needed. The shipped default stays a singleton — it is stateless and depends on nothing. `ClaimsMappingContext` describes who the claims are being built for: | Member | Meaning | | - | - | | `ClientId` | `client_id` of the requesting relying party; `null` when the request carries none | | `TenantId` | tenant of the request; `null` when the tenant level is not set | | `Scopes` | scopes requested by the relying party | The attribution is what a pairwise subject identifier needs (OIDC Core 1.0 §8.1): the same end user gets a different `sub` per relying party. The built-in mapper does not use it — the `sub` it issues is global across relying parties. ### Transaction event handler Implement `ITransactionEventHandler` to react to transaction events (for example, to link a notification channel on sign-in): ```csharp theme={null} authServer.AddTransactionEventHandler<MyTransactionEventHandler>(); ``` The handler is added **alongside** the built-in ones rather than replacing them: the port is consumed as `IEnumerable<ITransactionEventHandler>`, so every registered handler receives every event. The shipped handlers keep running — the sign-in window still gets its completion event, and the audit trail still gets its records. ### Idempotent transaction creation `CreateTransactionRequest` accepts the `IdempotencyKey` + `IdempotencyScope` pair: a repeated request carrying the same pair returns the transaction already created instead of starting a second one (and is refused with `idempotency_key_conflict` when the creation parameters differ). Among the built-in entry points, `POST /api/transaction/confirmation` — the creation of a confirmation transaction over client credentials — fills the pair in: the key comes from the `idempotency_key` field of the request body and the scope from the authenticated `client_id`, so the same key from two applications denotes two different requests. A repeat with the same key answers with the same transaction and a freshly built way in. The browser authorization endpoint does not fill the pair in: there a repeated request means a new sign-in rather than a retry of the previous one. If you drive the engine directly, filling the pair in is up to you. ## Next steps <CardGroup> <Card title="Storage" icon="database" href="/docs/guides/storage"> Transaction store and the OpenIddict database: InMemory / EF Core / Redis, PostgreSQL. </Card> <Card title="Initiator context" icon="location-crosshairs" href="/docs/concepts/initiator-context"> What the confirmation shows: who initiated the sign-in and from where. </Card> <Card title="Production hardening" icon="shield-check" href="/docs/guides/hardening"> The pre-production checklist. </Card> <Card title=".NET quickstart" icon="rocket" href="/docs/quickstart/dotnet"> The basic integration in a few minutes. </Card> </CardGroup> # Security and data handling Source: https://veriqa.app/docs/guides/data-handling What Veriqa collects, where it stores it, how long it keeps it, what it encrypts and what leaves your perimeter. This page answers the questions a security review asks first. <Note> This is a factual description of the software, not legal advice and not a compliance certification. Which of these facts matter under your regulator, and what you have to do about them, is a decision for you and your counsel. </Note> ## Who holds what Veriqa is delivered as code you run. In the self-hosted model that is the whole answer to "where does the data go": * **You are the controller.** The database, the key material and the logs are on your infrastructure, under your connection strings. Nothing is provisioned for you. * **Veriqa (the vendor) is neither controller nor processor of your users' data.** No user data reaches the Veriqa vendor: the product does not phone home, ships no telemetry and has no vendor endpoint of any kind. The only outbound addresses in the code are the messenger APIs you configure and the SMTP server you point it at (see [What leaves your perimeter](#what-leaves-your-perimeter)). * **The messenger providers are your counterparties, not the Veriqa vendor's.** Telegram, the WhatsApp Cloud API and every other messenger you enable see the messages you send through your own bot credentials. Your relationship with each of them is direct. ## Personal data inventory Everything the shipped product touches, and nothing beyond it. | Data | Where it comes from | Where it lives | For how long | | - | - | - | - | | Channel user id (Telegram/MAX/WhatsApp id, email address) | the channel, on confirmation | transaction store; `sub` claim of the issued token | transaction: minutes. Token: your token lifetimes | | Display name, first/last name, username | the channel profile | transaction store; `name`, `given_name`, `family_name`, `preferred_username` claims | as above | | Email address, `email_verified` | the channel (or typed by the user in the Email channel) | transaction store; `email` claim | as above | | Phone number | the channel, where it exposes one | transaction store; `phone_number` claim | as above | | Avatar image | the channel profile; Telegram's is downloaded by the adapter (at most `AvatarDataUri.MaxImageBytes`, 64 KiB, so that the image fits the 128 KB transaction snapshot; a larger photo is not delivered, and the core drops a malformed or oversized value from any adapter, so the sign-in goes on without an avatar) and kept as a `data:` URI, MAX may give an `https` URL | transaction store (channel identity snapshot); `picture` claim, issued only to clients that request the `avatar` scope, inside the payload of their access and refresh tokens in the OpenIddict database | transaction: minutes. Token records: until pruned (see below) | | Locale | the channel profile | transaction store; `locale` claim | as above | | IP address of the initiator | the `/connect/authorize` request | initiator-context snapshot inside the transaction | transaction: minutes. Never displayed, never logged | | Browser, OS, device type | the `User-Agent` of that request, parsed | initiator-context snapshot | as above. Shown in the confirmation message | | Raw `User-Agent` string | the same header | initiator-context snapshot — **only if you turn it on** | as above | | Country and city | offline GeoIP lookup of that IP | initiator-context snapshot | as above. Shown in the confirmation message as "City, Country" | | Audit records | transaction and channel events | the audit sink you chose | 90 days by default; only when you enable the trail | | Relying-party expectations (an email or phone the application says it expects) | your application, when it starts a transaction | transaction store, **encrypted** | transaction: minutes | | Confirmed-operation parameters | your application | audit records — **only if you turn it on** | the audit retention period | | Claims Veriqa collected during a sign-in (phone, email, form fields) and the `Optional` claims the user declined | the user, on the completion step | the collected claims store, by tenant and `sub` — **only if you turn it on** | until deleted by `sub`, or by the retention you set | **Not in the list:** message bodies from the channel (a channel adapter reads what it needs to identify the sender and confirm, and keeps nothing else) and any long-lived profile of the user beyond the collected claims above. The product ships **no user directory** — see [Channel identities](#channel-identities-nothing-is-stored-by-default). ## Collection defaults These are on out of the box. Nothing here needs an action from you to start; each needs one to stop. | Setting | Ships as | What it means | | - | - | - | | `Veriqa:InitiatorContext:Enabled` | `true` | The initiator-context snapshot is built at all | | `Veriqa:InitiatorContext:CollectIpAddress` | `true` | The initiator's IP is written into the snapshot | | `Veriqa:InitiatorContext:CollectUserAgent` | `true` | The `User-Agent` is parsed into browser / OS / device type | | `Veriqa:InitiatorContext:CollectGeoLocation` | `true` | Country and city are looked up **when a GeoIP database is available and the IP is trusted** | | `Veriqa:InitiatorContext:StoreRawUserAgent` | `false` | The raw header is *not* stored; only the parsed fields are | Two qualifications that matter more than the flags: **Geolocation is on by default but inert by default.** The lookup needs the satellite package `Veriqa.Core.AuthServer.MaxMind` *and* a local `.mmdb` database, and the base packages carry neither. Without them the provider reports itself unavailable and the geo fields stay empty — so a stock installation collects no location, even though the flag says `true`. Adding the database is what starts the collection. The standalone image already carries the package: there, collection starts once you mount the database and point `Veriqa:InitiatorContext:GeoIp:DatabasePath` at it. The IP must also be trusted: while forwarded headers are accepted from any source (no `KnownProxies` / `KnownIPNetworks` configured), the lookup is skipped. **The IP is collected but never shown and never logged.** It goes into the snapshot inside the transaction and stops there: the confirmation message names only the application, browser, OS and region, and the diagnostic log for an untrusted forwarding configuration records a result code rather than the address. Turning all of it off is one section: ```json appsettings.json theme={null} { "Veriqa": { "InitiatorContext": { "Enabled": false } } } ``` With `Enabled: false` no snapshot is built, and the confirmation message simply carries no context line. You can also keep the context and narrow it — drop `region` from `DisplayFields`, or set `CollectIpAddress: false` (which also removes geolocation, since the lookup has no input). Details and the per-application narrowing are in [Initiator context](/docs/concepts/initiator-context). ## Where it is stored | Store | What it holds | Configured by | | - | - | - | | Transaction store | the whole live transaction: OIDC request context, the channel identity snapshot, the resolved identity, the initiator context | `ConfigureTransactionEngine` — InMemory, EF Core or Redis ([Storage](/docs/guides/storage)) | | OpenIddict database | OIDC clients, authorizations and tokens; the subject of a token is `{channel}:{channel_user_id}`. Access and refresh tokens are reference tokens: the client holds an identifier, and the full token — every claim it carries, `picture` included for clients with `avatar` — is kept in the token record's payload | `Veriqa:OpenIddict:Database` | | Audit sink | audit records, if you enabled the trail | `AddVeriqaAuditTrail` — in-memory, EF Core, or an implementation of yours | | Channel stores | Email action tokens (transaction id + normalized email), Email push correlations (transaction id only), prompt coordinates, processed-message ids, link handle fingerprints (transaction id + application id) | in-process by default; Redis for multiple replicas ([Storage](/docs/guides/storage)) | | Collected claims store | claims Veriqa collected and declined `Optional` claims, by tenant and `sub`, if you turned it on | `Veriqa:CollectedClaims:Store:Enabled` or `UseEfCoreCollectedClaimsStore` ([Storage](/docs/guides/storage#collected-claims-store)) | | DataProtection key ring | the keys protecting the relying-party expectations | `Veriqa:DataProtection:RedisConnectionString` or `:KeysDirectory` | ### Channel identities: nothing is stored by default The port that would persist a channel identity — channel user id, phone, email, display name, as an upsert with neither TTL nor deletion — is `IChannelIdentityRepository`, and **Veriqa ships no implementation of it and registers none.** Sign-in works without one: the resolved identity travels with the transaction and is gone with it. Keeping that data indefinitely is an act you perform deliberately, by registering an implementation of your own; see [Storage](/docs/guides/storage#channel-identities-nothing-is-stored-out-of-the-box). ## Retention | What | Ships as | Where the number comes from | | - | - | - | | A live transaction | 300 s to confirm | `Veriqa:TransactionEngine:TransactionTtlSeconds` (60–1800) | | A dead transaction (completed, failed, expired) | readable for a further 600 s, then deleted | `CompletedRetentionSeconds` (60–3600); the sweep runs every `CleanupIntervalSeconds` (60 s) | | Audit records | 90 days | `Veriqa:Logging:RetentionDays`; the sweep runs hourly in batches of 500 | | Email action tokens, push correlations, prompt coordinates | until their own `ExpiresAt` | the entity itself | | Processed-message records (inbound mail dedup) | 24 hours | `ProcessedMessageRetention` | | Access / refresh tokens, authorization codes | valid for 1 h / 14 d / 5 min; the record is deleted once it is no longer valid and more than 24 h old | `Veriqa:OpenIddict:Server:*LifetimeSeconds`; deletion — `TokenPruneThresholdSeconds`, checked every `TokenPruneIntervalSeconds` (1 h) | | Channel identities, if you register the port | **forever** | the contract has no TTL and no deletion — it is yours to bound | | Collected claims, if you turn the store on | **forever** unless you set a retention | `Veriqa:ClaimCompletion:CollectedClaims:RetentionDays`, counted from the last sign-in that read them; deletion by `sub` — [Storage](/docs/guides/storage#deleting-by-sub) | In short: **the transaction store empties itself within about fifteen minutes** of a sign-in, and the audit trail is the only Veriqa store that keeps anything for days. Everything with an indefinite lifetime is something you added — a channel identity repository, the collected claims store, or tokens with a lifetime you chose. <Warning> **The OpenIddict database is pruned by the auth server.** A background job deletes token and authorization records that are no longer valid (expired or revoked) and were created more than `Veriqa:OpenIddict:Server:TokenPruneThresholdSeconds` ago (24 h by default), every `TokenPruneIntervalSeconds` (1 h), on every store provider. Valid records are never deleted. Until a record is pruned, its payload — including an avatar image for clients with `avatar` — stays in the database, so a lower threshold shortens how long that data is kept. </Warning> ### The audit trail is off until you ask for it, twice Two independent switches, and both are needed: 1. The satellite is opt-in — a host that never calls `AddVeriqaAuditTrail` has no trail at all. 2. `Veriqa:Logging:Mode` ships as `System`, and records are written **only** in `Audit`. `Disabled` and `System` are the same thing to the receiver: write nothing. Retention, once records exist, is not a third switch: the sweep is registered automatically for either built-in sink and deletes anything older than `RetentionDays`. `ConfigureRetention` only tunes how often the sweep runs and how large its batches are — it does not turn retention on, and skipping the call does not leave records unbounded. A retention of zero or less is refused at startup rather than read as "keep nothing". The one case where nothing sweeps: **a custom sink you registered with `UseSink<TSink>()`.** Records then live in your store, the built-in sweep is not registered, and the host says so at startup. Bounding that store is up to you. ```json appsettings.json theme={null} { "Veriqa": { "Logging": { "Mode": "Audit", "RetentionDays": 90 } } } ``` ### What an audit record actually contains A closed set of attributes, fixed in the schema — one column per attribute, no free-form payload: the timestamp, the actor, the action code, the target (the transaction id), the outcome and its reason code, plus the transaction type, channel type, correlation id, tenant, `client_id`, UI locale and time zone, the channel's own safe details and its declared inbound verification level. **The actor is masked.** For an action the user performed in a channel it is `{channel}:{masked id}` — at most the last two characters of the channel user id stay readable. The unmasked value never leaves the transaction store. **No IP, no User-Agent, no geolocation, no display name, no email** reaches an audit record. The initiator context lives in the transaction and dies with it. The single exception you can switch on is the parameters of the confirmed operation — the declared action kind and its slot values. Those are your application's domain data and may be anything, so they are off by default and copied into the record only when you ask ([Hardening](/docs/guides/hardening#logging-and-audit)). ## Encryption | What | Encrypted by Veriqa? | | - | - | | Relying-party expectations (the email/phone your application says it expects) | **Yes** — ASP.NET Core Data Protection, an isolated purpose string, values readable across key rotation while the old key is in the ring | | Everything else in the transaction store — identity snapshots, initiator context, OIDC request context | **No.** Written as ordinary columns / Redis values | | Audit records | **No** | | OpenIddict tokens | Access and refresh tokens reach the client as opaque identifiers; the token kept in the record payload of the OpenIddict database is encrypted by OpenIddict with your encryption certificate. The `id_token` is signed, not encrypted | | The database files, the Redis dump, the backups | **No** — that is your storage layer: transparent encryption, volume encryption and an encrypted DB connection are set up by your operator | There is no application-level encryption of stored personal data beyond that one protected field, and no key management of its own. If your threat model requires personal data encrypted at rest, that requirement is met at the storage layer you operate — encrypted volumes, TDE, an encrypted connection to the database — not by a setting in Veriqa. Secrets — `client_secret`, bot tokens, SMTP credentials — belong in environment variables or a secret store, never in config files in the repository. Passwords are never written to a log: where one has to be compared, the comparison is over a SHA-256 hash. ## Keys Three kinds of key material, three different homes. **Token signing and encryption certificates.** In `Development` the product uses OpenIddict's development certificates. Outside `Development` it loads X.509 certificates you supply, by path or base64 (`Veriqa:OpenIddict:Server:SigningCertificate*` / `EncryptionCertificate*`), and refuses to start without them. <Warning> **Replacing a certificate is a session-invalidating operation.** The configuration accepts exactly one signing and one encryption certificate, so JWKS carries a single signing key and old and new never overlap. Tokens signed with the previous key stop validating the moment you swap it. Plan key replacement as a maintenance event, not as a background rotation — the same warning appears in [hardening](/docs/guides/hardening#issuer-and-certificates). </Warning> **The DataProtection key ring.** Redis takes priority (`Veriqa:DataProtection:RedisConnectionString`), then a directory (`Veriqa:DataProtection:KeysDirectory`); **with neither set, the keys live in memory and are lost on restart.** That is fine for development and wrong everywhere else — after a restart the protected expectations of live transactions can no longer be read. Data Protection rotates its own keys, and values written under an earlier key stay readable while that key is still in the ring, so a ring on durable storage makes rotation a non-event. **Channel and application secrets** — bot tokens, webhook secret tokens, `client_secret`, SMTP credentials. Rotating them is a channel-side and client-side operation; nothing in Veriqa caches them past a configuration reload. ## What leaves your perimeter Only what you configured a channel for. Per confirmation, the outbound message carries: * the **name of the application** that started the sign-in; * the **browser**, **OS** and **region** of the initiator, where those are known and displayed; * a **link or buttons** to confirm, and how long the request is valid. It does not carry the IP address, the raw `User-Agent`, the user's own profile data, or anything your application declared about the transaction. Where it goes depends on the channel: `api.telegram.org` for Telegram, `platform-api.max.ru` for MAX, `graph.facebook.com` for the WhatsApp Cloud API, and your own SMTP server for Email. In the other direction, each channel returns the sender's profile — the fields in the inventory table above. The messenger sees the message content and the recipient because that is what delivering a message means; the Email channel sees whatever your SMTP relay sees. **The external event bus.** If you wire the RabbitMQ publisher, transaction events leave the process carrying the safe attribution only, including the masked channel identity. The parameters of a confirmed operation are explicitly excluded from serialization and never reach the bus, whatever the audit configuration says. ## Logs Application logs are not an audit trail and are not designed to be evidence. What matters for this page is what they must never contain, and what the code does about it: identifiers are written as a **16-character non-reversible fingerprint** (the leading bytes of a SHA-256) rather than as themselves, so an operator can still stitch the steps of one sign-in together without the log holding the identity. Tokens, secrets and the initiator's IP are not logged at all. That is the shipped behavior of Veriqa's own log statements. Your host, your reverse proxy and your platform's request logging are outside it — an access log in front of the auth server records client IPs by default, and bounding *that* is part of the same deployment step that configures `ForwardedHeaders:KnownProxies`. ## What is left for you to decide Nothing on this list has a right answer the product can pick for you. * Whether to keep the initiator context at all, and whether to add a GeoIP database. * Whether to enable the audit trail, at what retention, and whether records should carry the parameters of confirmed operations. * Whether to persist channel identities — and, if so, how they get deleted. * Whether to turn on the collected claims store, and with what retention. * How long expired and revoked token records stay in the OpenIddict database before pruning. * Where the DataProtection key ring lives. * Encryption at rest, backups and their retention, at the storage layer. * How a deletion request from a user is executed across your stores. Veriqa deletes its transaction rows on its own schedule and its audit rows on retention; the collected claims store you reach by `sub` (endpoint or host call); anything you added is yours to reach. ## Next steps <Card title="Production hardening" icon="shield-check" href="/docs/guides/hardening"> The checklist to run before going live. </Card> <Card title="Storage" icon="database" href="/docs/guides/storage"> Which store holds what, and how to wire each one. </Card> <Card title="Known limitations" icon="triangle-exclamation" href="/docs/reference/limitations"> What the product does not do. </Card> # You already have a bot Source: https://veriqa.app/docs/guides/existing-bot Your product already runs a bot in the messenger your users sign in through. The ways to use it together with Veriqa: which one to choose, who owns the bot token and the incoming events, what changes on your side, and what works today versus what is planned. You run a bot of your own — orders, support, notifications — and want sign-in and confirmation through Veriqa in the same messenger. There are several ways to combine the two. Three of them work today; three more are planned. This page compares them and points to the page that documents each. <Warning> **Do not put the token of a bot that is already running into the settings of a built-in adapter.** A built-in adapter that registers the webhook itself — MAX, Telegram and Viber — registers its own webhook for that token when the application starts and removes it when the application stops. In Telegram and Viber your bot's webhook is taken over, and the bot stops receiving its events; after a shutdown the token is left with no webhook at all. The MAX adapter subscribes its own address with the platform at startup and removes that subscription at shutdown. When it applies: MAX and Telegram — in `UpdateMode: Webhook`, which is the default; Viber — always. In `Polling` mode the MAX adapter subscribes no webhook but fetches the updates itself, with the bot token. In `Polling` mode the Telegram adapter registers no webhook, but another limit applies — **one poller per bot**, see the [Telegram section of the channel setup](/docs/guides/channels#telegram). To share one bot between your code and Veriqa, use one of the two shared-bot options below — in both of them the incoming events are received by your side, not by a built-in adapter. </Warning> ## Which one to choose | Option | In short | Status | | - | - | - | | [Two bots](#two-bots) | Veriqa runs a sign-in bot of its own; after the sign-in it shows a button that opens your bot, and the button's address carries a single-use handle your server exchanges for the identity of the user who signed in. You keep the link | Works today | | [A shared bot through an external HTTP adapter](#a-shared-bot-through-an-external-http-adapter) | A service of yours owns the incoming events, hands the Veriqa ones to Veriqa and the rest to your bot | Works today | | [A shared bot in process, with your own .NET adapter](#a-shared-bot-in-process-with-your-own-net-adapter) | Your own adapter splits the events with `OwnsInboundEvent` | Works today | | [A built-in adapter that leaves the bot's transport alone](#planned) | The token of a running bot, put into the settings, keeps its webhook | Planned | | [A gateway for the built-in adapter](#planned) | Veriqa receives the incoming events, handles its own and delivers the rest to your bot; a bot on polling changes only the API address | Planned | | [Confirmation by your own bot](#planned) | Your bot shows the question itself and reports the decision in one call — for bots built on no-code platforms | Planned | Choosing at a glance: * **You can publish a second bot, and a separate sign-in bot is fine for your users** — two bots. Nothing changes in the bot you already run, apart from handling one start parameter. * **Users must stay in one bot, and you are willing to run one more service** — the external HTTP adapter, in any language. * **Users must stay in one bot, and your host is .NET** — your own adapter in process. ## Who owns the token and the incoming events | Option | Bot token | Incoming events are received by | | - | - | - | | Two bots | Two tokens: the sign-in bot's is in the Veriqa settings, your bot's stays with you | Each bot receives its own | | A shared bot through an external HTTP adapter | One token, held by your adapter service. Veriqa never asks for it | Your adapter service; it posts the Veriqa events to Veriqa | | A shared bot in process, with your own .NET adapter | One token, held by your code in the host | Your code; it passes the Veriqa events to the adapter | ## Two bots **When to choose.** A separate bot for sign-in is acceptable, and you want as few changes as possible to the bot you already have. **What you do.** Put the handle placeholder into the address of the button the sign-in bot shows after the sign-in; your bot receives the handle as its start parameter; your server exchanges it for the identity of the user and stores the link "the user of my bot ↔ `sub`" on your side. Veriqa keeps no link. **Known limitations:** * The sign-in completes without the button being pressed. If switching to your bot is mandatory, your application is what enforces it. * The handle is single-use and short-lived. * With several replicas the handle needs a shared store. * Your bot can start a conversation only with a user who has already opened it. * The button can be forwarded, and whoever presses it brings the handle to your bot: check who pressed the button. Where there is nothing to compare with, the link goes to whoever presses it first. Details, the request and the response, and the recipe for a mandatory switch — [Two bots: handing the user over to your bot](/docs/scenarios/channel-linking#two-bots-handing-the-user-over-to-your-bot). ## A shared bot through an external HTTP adapter **When to choose.** One bot for both sign-in and your own scenarios, and either you are not tied to .NET, or you run the standard Veriqa image and do not want to rebuild it. **What you do.** Run an external adapter — a service of yours — next to Veriqa and register it as an HTTP channel through configuration. The adapter receives the platform's events, posts the Veriqa ones to Veriqa and forwards the rest to your bot. The reference adapter for Telegram in Python, `samples/python/custom-channel-telegram/`, does exactly that: `TELEGRAM_BOT_FORWARD_URL` names the address your bot listens on, and the routing table in its README says which update goes where. **Known limitations:** * It is a separate service to run and to protect. * The channel has a type of its own, different from the built-in one: `telegram-http` in the reference adapter, not `telegram`. * In the reference adapter an update that could not be forwarded is only written to the log — there is no queue and no retry. * In the reference adapter a bot with a forward address passes the shared contact on to your bot as well, so the phone number on request is declared only for a bot without one. Details — [An HTTP channel adapter in any language](/docs/guides/custom-channel-adapter#an-http-channel-adapter-in-any-language) and its [reference adapters](/docs/guides/custom-channel-adapter#reference-adapters). ## A shared bot in process, with your own .NET adapter **When to choose.** One bot, your host is .NET, and you do not want another service or another hop. **What you do.** Write a channel adapter of your own and keep the transport of the bot in your code: it receives every event and asks the adapter's `OwnsInboundEvent` whether the event belongs to Veriqa. The Veriqa events go to Veriqa, everything else to your bot. **Known limitations:** * .NET only. * `OwnsInboundEvent` is a cheap substring check and can err in one direction only: override it with a precise check of your wire format if a false positive costs you something. * If the adapter declares the phone number on request, `OwnsInboundEvent` has to be overridden, keeping the marker check `CallbackDataPrefixes.ContainsAnyMarker`: the default body recognizes the button markers, but a shared contact carries none. * A phone number step button that sends plain text carries no marker at all, so its press reaches your bot, not Veriqa: an optional number is then skipped when its window runs out, and a required one waits until the transaction expires. A button with a payload — marked with `CallbackDataPrefixes.PhoneSkip` or `CallbackDataPrefixes.PhoneCancel` — is recognized by the marker check. See [phone number on request](/docs/guides/custom-channel-adapter#phone-number-on-request); the built-in Telegram adapter has the same limitation — [phone number in Telegram](/docs/guides/phone-number#telegram). Details — [Build your own channel adapter](/docs/guides/custom-channel-adapter) and [phone number on request](/docs/guides/custom-channel-adapter#phone-number-on-request). ## Planned None of the three is available yet; each removes one of the limitations above. * **A built-in adapter that leaves the bot's transport alone.** You will be able to put the token of a bot that is already running into the settings of a built-in adapter without the adapter taking over its webhook. * **A gateway for the built-in adapter.** Veriqa will receive the incoming events of the shared bot, handle its own and deliver the rest to your bot — with the built-in channel and no adapter to write. A bot that uses polling will only change the API address it calls. * **Confirmation by your own bot.** Your bot will show the confirmation question itself and report the user's decision to Veriqa in one call. It is meant for bots built on no-code platforms, where nothing can be placed in front of the bot. <Card title="Supported channels" icon="comments" href="/docs/guides/supported-channels"> Which channels ship with Veriqa and what each of them returns. </Card> # Production hardening Source: https://veriqa.app/docs/guides/hardening The checklist to run before shipping a Veriqa integration to production. This is a checklist of the things teams most often miss before going live. It is **not** a full deployment guide — it focuses on the easy-to-skip items. Checklist state is stored locally in your browser; use the browser's print-to-PDF if you want a copy. <Note> **What the product collects, stores and keeps** — the inventory, the collection defaults, the retention periods and what leaves your perimeter — is on one page of its own: [Security and data handling](/docs/guides/data-handling). This checklist is about the decisions you make; that page is about the facts you make them on. </Note> ### Issuer and certificates * [ ] Stable HTTPS issuer, current JWKS, a planned key replacement procedure. The issuer must be a stable HTTPS address — it lands in the `iss` claim and in discovery. Clients must trust keys via discovery / JWKS, never pinned static keys: then replacing a key needs no change on their side. Outside `Development` the signing and encryption certificates are required in both delivery modes, embedded and self-hosted — without them the host does not start (keys: [configuration reference](/docs/reference/configuration#server)). <Warning> **Zero-downtime rotation is not supported.** The configuration accepts **one** signing certificate and **one** encryption certificate, so JWKS carries a single signing key — old and new keys never overlap. Replacing the certificate invalidates live tokens signed with the previous key; the same warning appears in the [self-hosted quickstart](/docs/quickstart/self-hosted#3-provide-the-token-certificates). Plan key replacement as an operation that invalidates sessions, not as a background task. </Warning> ### Client secrets and storage * [ ] `client_secret` and channel tokens — out of the repo, via the environment, encrypted and rotated. Secrets (client `client_secret`, bot tokens, channel API keys) are passed only through environment variables / a secret store — not config files, not git. Encrypt at rest and rotate. ### OIDC PKCE and state * [ ] PKCE (`S256`) required for public clients; validate `state` and `nonce`. PKCE with the `S256` method is mandatory for public clients. `state` guards against CSRF on the redirect, `nonce` binds the `id_token` to the session. Veriqa additionally checks a one-time browser nonce in the callback — do not disable these checks. ### Redirect URIs * [ ] Exact-match allowlist; no wildcards in production; `https` only (except loopback in dev). The redirect URI is matched exactly. No wildcards in production; `http` only for loopback addresses (`localhost`, `127.0.0.1`, `[::1]`) during development. ### Scopes and claims * [ ] Minimal scope set; `openid` required; do not request unnecessary PII. Request the minimum: `openid` is required, the rest as needed. Do not request `email` unless needed; do not store PII beyond necessity. `/userinfo` returns only the claims permitted by scopes and configuration. ### Rate limiting and anti-abuse * [ ] Core limits sized for the traffic of each route; a per-IP limiter on the reverse proxy / WAF. The core throttles every public surface with **one shared bucket per route** — `authorize`, `token`, the callback, polling, webhooks, SignalR, the Email start page, the confirmation pages (defaults in [configuration](/docs/reference/configuration#veriqa-ratelimit)). On top of that it counts sign-in attempts **per user** (channel identity), so a confirmation cannot be brute-forced, and it gives each relying party **its own budget** on the server-to-server confirmation entry point. Size the shared buckets for the total traffic of the route: they protect availability, not one visitor from another. What the core does **not** do is key any limit by client IP or block addresses. A per-IP limit catches the one thing the other two keys cannot see — a **horizontal spray**, many accounts tried from one address, each staying under its own per-user limit — and that layer belongs at the edge you already run: `limit_req` on NGINX, a rate rule on your WAF, fail2ban on the `429`s in the access log. The same deployment step configures `ForwardedHeaders:KnownProxies` (see [self-hosted, step 6](/docs/quickstart/self-hosted#6-put-it-behind-a-reverse-proxy)): the edge sees the real client address, the core only trusts what the edge forwards. An installation without an edge limiter is open to the spray, so put one in place before going live. ### Refresh token rotation * [ ] Enable rotation and revocation; react to reuse. Enable refresh-token rotation and revocation. On reuse (replay), revoke the whole token family. ### Logging and audit * [ ] Never log tokens / secrets / PII; enable transaction auditing. Tokens, secrets and personal data never reach logs. Enable the transaction audit trail — it makes sign-ins and confirmations traceable. The trail is written from transaction events — created, confirmed, completed, failed, expired — and from channel security events. Every record carries the timestamp, the actor, the event code (`action`), the target and the outcome (`result`). <Note> **Two known limitations of the trail** — account for them when you plan reporting and incident analysis. **Token issuance is not audited.** Neither the initial issuance via authorization code nor refresh-token rotation creates an audit record: the trail is written from transaction events, and token issuance does not travel through them and is not tied to a transaction — even less so rotation, which happens outside the transaction lifecycle altogether. Do not build proof of token delivery on the audit trail: issuance and rotation are controlled by token revocation and family revocation on replay (see [Refresh token rotation](#refresh-token-rotation)). **The outcome of a channel security record is uninformative.** For records produced by channel security events the outcome field is always the same — failure — for any channel code. A failure in such a row does not mean something broke: the meaning is carried by the code in the `action` field, and the codes are listed in the documentation of the specific channel. Filter and analyze these records by `action`, not by outcome. </Note> **Parameters of the confirmed operation — opt-in.** By default the records of a confirmation transaction do not say *what exactly* was confirmed: the declared action kind and its slot values are business data of your application and may contain personal data. If the trail has to answer "which operation, with which parameters, was confirmed", turn it on deliberately: ```csharp Program.cs theme={null} services.AddVeriqaAuditTrail(audit => { audit.UseInMemorySink(); audit.ConfigureRecord(record => record.IncludeConfirmationParameters = true); }); ``` Once on, the parameters are copied **into the record itself** rather than referenced through the transaction: a completed transaction is swept within minutes, while the record lives for the whole retention period. **Every** audit record of such a transaction gets them — not the confirmation alone, but the creation, the completion, the failure and the expiry too: the question "which operation was this" is asked of those as well. They never leave the process — the external event bus does not carry them. ### Inbound verification level of a channel * [ ] `InboundVerification` is declared for every channel section — and declared deliberately. Every channel section must carry `InboundVerification`: a deployment with an enabled shipped channel and no value does not start. The startup check walks the very set of channels the core builds its enabled list from: all shipped channels are in it, a channel of a third-party adapter plugged in with a bare `AddChannel` call is not — you have to check its section manually. Look not at whether the key is there but at **what it says**: the value must match the mode the channel actually runs in (the table is in [Channel setup](/docs/guides/channels)). A mismatch here is silent — a channel in webhook mode declaring `outbound_fetch` starts without any warning. `none` is a **deliberate choice, never a default**: it says "the authenticity of an incoming event is not verified", i.e. the confirmation endpoint of that channel accepts a request from anyone. Set it only if you have genuinely accepted that risk, and record the decision where your other risk decisions live. ### Channel availability * [ ] Define behavior when a channel is unavailable. Decide up front what happens when a channel is down: offer an alternative channel or show a clear error — never leave the user stuck in an unfinished transaction. ### Localization and accessibility * [ ] WCAG 2.2 AA; locale files for your audience's languages. Sign-in and confirmation pages are designed to meet WCAG 2.2 AA. The standalone host built from `build/docker/Dockerfile` ships English, Russian and Chinese translations; in the embedded mode the locale catalog is supplied by your host, and without it the page is shown in English (see [Localization](/docs/guides/customization#localization)). No formal accessibility audit with a published report has been carried out — if your regulations require one, plan it on your side. ### Versioning and migrations * [ ] SemVer SDK; relational stores via migrations, not auto-create. Follow SemVer and plan minor / major upgrades. Initialize relational stores with migrations (schema history), not auto-create. ### Operational essentials * [ ] Health probe, encrypted DB channel, secret storage, external JS on the sign-in page off. A host health probe, an encrypted database connection, secrets in a secret store. External JS on the auth page is disabled by default — enable it only deliberately (see [customization](/docs/guides/customization#external-css)). ## Next steps <Card title="OIDC + Veriqa explained" icon="key" href="/docs/concepts/oidc-explainer"> Understand exactly where Veriqa sits in the OIDC flow. </Card> # Observability Source: https://veriqa.app/docs/guides/observability Metrics, traces, logs and health endpoints Veriqa exposes, and how to export them with OpenTelemetry. Veriqa is instrumented with the standard .NET primitives — `System.Diagnostics.Metrics` and `System.Diagnostics.ActivitySource` — and exports nothing by itself. The components own their instruments; the host subscribes to them and decides where the data goes. The names on this page are a public contract: build dashboards and alerts on them. | Signal | Name to subscribe to | | - | - | | Metrics (meters) | `Veriqa.Core.TransactionEngine`, `Veriqa.Core.ChannelAdapter` | | Traces (activity sources) | `Veriqa.Core.TransactionEngine`, `Veriqa.Core.ChannelAdapter` | ## Turning the export on <Tabs> <Tab title="Self-hosted image"> The image already subscribes to both meters and both activity sources, together with the ASP.NET Core, `HttpClient` and .NET runtime instrumentation. The export starts when the standard OpenTelemetry variable names a collector: ```bash theme={null} OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317 ``` Without the variable nothing is exported. With it, metrics, traces and logs all go over OTLP; the other standard `OTEL_EXPORTER_OTLP_*` variables (protocol, headers) and `OTEL_SERVICE_NAME` are read by the OpenTelemetry SDK as usual. The image has no scrape endpoint of its own — there is no `/metrics`. For Prometheus, point the image at an OpenTelemetry Collector and let the collector expose or push the metrics. </Tab> <Tab title="Embedded in your host"> `AddVeriqaAuthServer` registers the instruments and nothing else: your host owns the OpenTelemetry pipeline. Add the two names to it — the constants live in the MIT-licensed `Veriqa.Core.Contracts` package: ```csharp theme={null} using OpenTelemetry.Metrics; using OpenTelemetry.Trace; using Veriqa.Core.ChannelAdapter.Constants; using Veriqa.Core.TransactionEngine.Constants; builder.Services.AddOpenTelemetry() .WithMetrics(metrics => metrics .AddMeter(TransactionTelemetry.MeterName) .AddMeter(ChannelTelemetry.MeterName)) .WithTracing(tracing => tracing .AddSource(TransactionTelemetry.ActivitySourceName) .AddSource(ChannelTelemetry.ActivitySourceName)); ``` Exporters, sampling and the ASP.NET Core instrumentation are yours to choose. A host with no OpenTelemetry pipeline pays nothing for the instruments and gets no data. </Tab> </Tabs> ## Metrics ### Transactions — meter `Veriqa.Core.TransactionEngine` | Metric | Type | Unit | Tags | | - | - | - | - | | `veriqa.transaction.initiated` | counter | — | `veriqa.transaction.type` | | `veriqa.transaction.terminated` | counter | — | `veriqa.transaction.outcome`, `channel_type` | | `veriqa.transaction.duration` | histogram | `s` | `veriqa.transaction.outcome` | * **`initiated`** counts transactions that entered the lifecycle; **`terminated`** counts the ones that reached a terminal state, once each — a repeated terminal transition is not counted twice. * **`duration`** is the lifetime from creation to the terminal state. With a user in the loop it is dominated by how long the person takes to answer, not by the server. * The sign-in funnel is `initiated` against `terminated` with the outcome `completed`; the refusal share is the share of the other outcomes, `superseded` aside — that one is a repeated attempt, not a refusal, and is kept apart from `failed` so that retries do not raise the share of failures. | Tag | Values | | - | - | | `veriqa.transaction.type` | `login` — a sign-in; `confirmation` — a [server-to-server confirmation](/docs/reference/confirmation-api) | | `veriqa.transaction.outcome` | `completed` — confirmed and finished; `declined` — the user refused; `expired` — no answer within the TTL; `failed` — a failure that is not the user's refusal; `superseded` — a sign-in closed because the same user [started a newer one](/docs/concepts/auth-flow#starting-the-sign-in-again): in events and the audit trail it is a failed transaction with the reason `superseded_by_new_sign_in` | | `channel_type` | The type of the channel that answered, else of the channel that was asked; `unknown` when the transaction ended with no channel involved — typically an expiry before anyone answered | <Note> `completed` is deliberately not `confirmed`. The outcome shown to the user in the messenger is "confirmed"; the engine records `completed` one step later, when the identity is resolved and stored. A sign-in confirmed in the channel can therefore still end as `failed`. </Note> ### Channels — meter `Veriqa.Core.ChannelAdapter` | Metric | Type | Tags | What it counts | | - | - | - | - | | `veriqa.channel.notice.failures` | counter | `channel_type`, `error_code`, `notice_kind` | Messages the channel failed to deliver to the user | | `veriqa.channel.start.unserved` | counter | `channel_type` | Sign-in start events the core could not serve: the transaction they name is gone, its window has closed, or it no longer accepts the event | | `veriqa.channel.event.foreign_transaction` | counter | `channel_type` | Channel events naming a transaction that belongs to a tenant other than the owner of the channel registration they arrived through | * **`notice.failures`** is the one to alert on first: a growing series means users are not getting the confirmation prompt (`notice_kind` = `message`) or the final outcome (`notice_kind` = `outcome`). `error_code` is the adapter's error code, always from a code registry. * **`start.unserved`** makes a silence visible. The user opened a stale link or scanned an old QR, and a channel with no reply text configured answers nothing — the counter still moves. * **`event.foreign_transaction`**: the sender gets exactly the same answer as for an unknown transaction, so the answer reveals nothing. A sustained rate is someone trying identifiers that are not theirs. <Note> **No identifiers in metric tags.** Tags never carry a transaction, session or channel-user identifier, a recipient or message text. Every tag is a closed set, so the number of time series is bounded by your channels and the values above. </Note> ## Traces | Span | Source | Opened when | | - | - | - | | `veriqa.transaction.create` | `Veriqa.Core.TransactionEngine` | A transaction is created — the root of the sign-in trace | | `veriqa.channel.prompt.send` | `Veriqa.Core.ChannelAdapter` | The confirmation prompt is sent to the messenger | | `veriqa.channel.inbound` | `Veriqa.Core.ChannelAdapter` | A channel event arrives over the webhook route | | `veriqa.transaction.complete` | `Veriqa.Core.TransactionEngine` | The transaction is finalized from channel data: confirm, resolve the identity, complete | | Span attribute | Meaning | | - | - | | `veriqa.transaction.id` | Identifier of the transaction the span acts on | | `veriqa.transaction.type` | Transaction type, on `veriqa.transaction.create` | | `veriqa.transaction.trace_id` | Trace identifier of the trace that created the transaction, on `veriqa.channel.inbound` | | `channel_type` | Channel type, on the channel spans | | `error_code` | Error code of the failure, on a span with the status `Error` | **A sign-in is two traces, not one.** The user answers any amount of time after the prompt, and the messenger propagates no trace context, so the inbound event opens a trace of its own. The two are stitched by an attribute: `veriqa.transaction.trace_id` on the inbound span holds the trace identifier of the trace that created the transaction. To get from the answer to the sign-in request, search your backend by that value. * For a sign-in the stored value is the trace identifier of the `/connect/authorize` request. If your application propagates `traceparent`, that is your application's own trace. * For a server-to-server confirmation it is the `correlation_id` you passed in the request. * The same value is written to the `CorrelationId` field of the audit record, so the audit trail and the traces are joined by it too. * A host without tracing stores no value for a sign-in — no identifier is invented in its place — and the inbound span is then left without the attribute. <Note> **Reading span status.** A span is marked `Error` for a failure that ends inside the operation it covers, with the reason in `error_code`. An unset status means "no failure ended here", not "the operation succeeded" — the outcome of the transaction is in the metrics and the audit trail. </Note> In `Polling` mode there is no inbound HTTP request and no `veriqa.channel.inbound` span; the `veriqa.transaction.complete` span is still opened. ## Logs In the self-hosted image logs follow the same switch as the other signals: with `OTEL_EXPORTER_OTLP_ENDPOINT` set they are exported over OTLP, with the formatted message and the logging scopes included. Log records written inside a span carry its trace and span identifiers, so a log line leads to its trace. What is and is not written to logs — and at which level — is covered in [Security and data handling](/docs/guides/data-handling#logs). ## Health endpoints These are the endpoints of the self-hosted image. | Endpoint | Answers | | - | - | | `/health/live` | Liveness — the process is up | | `/health/ready` | Readiness — configured dependencies answer. The probe for your orchestrator | | `/health/channels` | Per-channel state. Always `200` — read the body, not the status code | | `/health` | Every registered check at once | | `/alive` | The process-level check alone | Channels are deliberately absent from readiness: an unreachable messenger must not take a working replica out of rotation. What `/health/channels` does and does not tell you is described in the [self-hosted quickstart](/docs/quickstart/self-hosted#8-verify). # Phone number Source: https://veriqa.app/docs/guides/phone-number How Veriqa gets the user's phone number in each channel: the mechanism, whether the number is proven to be the user's, what the platform limits, and which part is Veriqa's work and which is yours. Veriqa takes a phone number **only from the channel**: the messenger hands it over, the user never types it. The sign-in page has no phone field, and a number typed into the chat as a message is not accepted. Your application receives it as the standard `phone_number` claim in E.164 format, next to `phone_number_verified`. ## When Veriqa asks for the number Veriqa asks only when your application needs the number and the channel has not already given it. The need is the requirement level of the `phone_number` claim: | Level | Where it comes from | | - | - | | `Required` | `RequirePhone` or `Level: Required` in [`Veriqa:ScopesClaims`](/docs/reference/configuration#veriqascopesclaims), `RequirePhone` or `ClaimLevels` of the [client entry](/docs/reference/configuration#clients), or `essential: true` on `phone_number` in the request's `claims` parameter | | `Optional` | `Level: Optional`, or `phone_number` in the request's `claims` parameter without `essential` | | `IfAvailable` (default) | nothing is asked: the number is issued only if the channel gave it on its own | A requirement counts only when the sign-in asks for the `phone` scope. With a `Required` number the sign-in page offers only the channels able to provide one; if none is left, `/connect/authorize` answers `400 no_channel_for_required_phone` ([troubleshooting](/docs/guides/troubleshooting)). The request comes once the user has confirmed the sign-in — in the chat or with "Yes" on the confirmation page in the browser. The bot sends it to the chat the user signed in from: a short text, a button that shares the number and a second button whose meaning depends on the level. The texts are shown in the user's language. The text of the request is a [message](/docs/reference/configuration#veriqamessagetemplates) whose kind follows the level: `phone-number-request` for an `Optional` number, `phone-number-request-required` for a `Required` one. For either the product ships `Share your phone number` — and a wording of its own for [Instagram](#instagram) and for [Messenger](#messenger) — and you may reword it for a tenant, for one application or for one channel — the last at `Veriqa:MessageTemplates:{kind}:BySurface:in-channel:ByChannel:{channel}:Templates`. The labels of the buttons are not part of that message: they are localizable strings, translated through the locale files. <Note> A wording stated for one kind does not apply to the other. If you reworded `phone-number-request` and ask for a `Required` number, state your wording under `phone-number-request-required` as well — otherwise the user gets the shipped text of that kind. </Note> | Level | Second button | No answer | | - | - | - | | `Optional` | **Skip** — the sign-in goes on without the number | the step is skipped when the window `ClaimCompletion.PhoneOptionalWindow` runs out | | `Required` | **Cancel sign-in** — the sign-in fails and your application receives `error=access_denied` | the transaction waits until it expires: no platform tells the bot that the user declined to share | The window is 60 seconds by default. Change it with `Veriqa:ClaimCompletion:PhoneOptionalWindow`, or for one application with the `ClaimCompletionPhoneOptionalWindow` member of its client entry (see [keys in the declaration catalog](/docs/reference/configuration#keys-in-the-declaration-catalog)). The window never runs past the transaction: the step is skipped at least 5 seconds before the transaction expires. When the shared number completes the sign-in, the chat shows the usual receipt of a confirmed sign-in; **Cancel sign-in** shows the receipt of a declined one. ## What your application receives * `phone_number` — in E.164: `+` and digits. A number a channel hands over in any other form counts as absent: it is not issued, and the step waits as if the user had not answered. * `phone_number_verified` — a JSON boolean: `true` only when the channel proves that the number belongs to the user who sent it, otherwise `false`. * Both claims go out only under a granted `phone` scope. A contact that fails the channel's ownership check — somebody else's contact card, a forwarded contact, a missing or wrong signature — is not accepted, and the bot asks again. A request that cannot be delivered to the chat even after retries changes nothing: the step waits as it does with no answer. ## Channel by channel The "Who" column shows whose code gets the number: Veriqa's channel adapter, or your own integration with the platform. Channels whose adapter is not shipped yet show what the platform's API allows. | Channel | How the number is obtained | Proven to be the user's | Who | Official sources | | - | - | - | - | - | | Telegram | the `request_contact` button of a reply keyboard; private chats only | yes — the contact's `user_id` is the sender, and the message is not forwarded | Veriqa | [Bot API: KeyboardButton](https://core.telegram.org/bots/api#keyboardbutton), [Log In With Telegram](https://core.telegram.org/bots/telegram-login) | | MAX | the `request_contact` button; the number comes in the contact's `vcf_info`. **Webhook mode only** | yes — the contact's `hash` matches HMAC-SHA256 of `vcf_info` keyed with the bot token | Veriqa | [keyboard](https://dev.max.ru/docs-api/use-cases/sending-messages/keyboard), [attachments](https://dev.max.ru/docs-api/use-cases/sending-messages/another-attachments) | | WhatsApp | automatically — the sender's number (`from` / `wa_id`) of a message, while WhatsApp sends it; without it, the `request_contact_info` interactive message | yes — a number from the message itself, or a contact sent from the request (`origin = contact_request`) | Veriqa | [Business-scoped user IDs](https://developers.facebook.com/documentation/business-messaging/whatsapp/business-scoped-user-ids/) | | Viber | the `share-phone` keyboard button; the number comes as a contact message. Viber clients with API level 3 and later | no | Veriqa | [keyboards](https://developers.viber.com/docs/tools/keyboards/), [REST Bot API](https://developers.viber.com/docs/api/rest-bot-api/) | | Messenger | the `user_phone_number` quick reply — the number from the user's profile. **Only when the profile has a number** | no | Veriqa | [quick replies](https://developers.facebook.com/docs/messenger-platform/send-messages/quick-replies) | | Instagram | the `user_phone_number` quick reply — the number from the user's Instagram profile. **Mobile app only, and only when the profile has a number** | no | Veriqa | [quick replies](https://developers.facebook.com/documentation/business-messaging/instagram-messaging/features/quick-replies) | | Slack | the `profile.phone` profile field, free text | no; a value that is not E.164 is not issued | Veriqa, once the adapter ships | [users.info](https://docs.slack.dev/reference/methods/users.info) | | Microsoft Teams | Microsoft Graph `mobilePhone`, the `User.Read.All` permission with admin consent | no | Veriqa, once the adapter ships | [Graph user](https://learn.microsoft.com/en-us/graph/api/resources/user) | | LINE | only LINE Login or LIFF with the `phone` scope (LINE Profile+, companies in Japan, on application) | — | you | [LINE Profile+](https://developers.line.biz/en/docs/partner-docs/line-profile-plus/) | | KakaoTalk | Kakao Login or Kakao Sync, the `phone_number` consent item, a Biz App and review | — | you | [Kakao Login](https://developers.kakao.com/docs/en/kakaologin/utilize) | | WeChat | the mini program `getPhoneNumber`, a verified business entity, paid | — | you | [getPhoneNumber](https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/getPhoneNumber.html) | | Zalo | the mini app `getPhoneNumber` plus a server-side token exchange | — | you | [getPhoneNumber](https://docs.zaloplatforms.com/docs/MA/api/user/user-information/getPhoneNumber) | | Discord | none | — | — | [User](https://docs.discord.com/developers/resources/user) | | Threema | none | — | — | [Threema Gateway](https://gateway.threema.ch/en/developer/api) | | Email | none | — | — | — | | A channel connected over HTTP | whichever way its registration declares: on request, automatically, or both | as the registration declares | your external adapter | [the phone number of an HTTP channel](/docs/guides/custom-channel-adapter#the-phone-number) | ### Telegram The bot sends a reply keyboard: the share button on top, the second button below it; the keyboard hides after a tap, and the message that reports the outcome of the sign-in removes it for good. A sign-in that expires with the request still waiting is reported as a new message for the same reason: only a new message removes the keyboard. Veriqa accepts only the user's own contact: the `user_id` of the contact must be the sender, and a forwarded contact or a card from the address book is rejected. Telegram's website login ([Log In With Telegram](https://core.telegram.org/bots/telegram-login)) can return the phone number as well — that is your own integration with Telegram, outside Veriqa. <Warning> If you route the bot's updates yourself — one bot shared by your code and Veriqa, and your code decides which update goes to Veriqa with `OwnsInboundEvent` (see [phone number on request](/docs/guides/custom-channel-adapter#phone-number-on-request)) — the **Skip** and **Cancel sign-in** buttons reach your bot, not Veriqa: in Telegram they are plain text buttons with no marker to recognize. The shared contact itself is recognized. An `Optional` number is then skipped by the window, and a `Required` one waits until the transaction expires. The other ways to share a bot are compared in [You already have a bot](/docs/guides/existing-bot). </Warning> ### MAX The bot sends the share button and a callback button with the second action. The number is taken from `vcf_info` only when the contact's `hash` signature matches — computed with the bot token of the tenant that received the contact. When the sign-in ends — confirmed, declined, failed or expired — the buttons are removed from the request message, and the outcome arrives as a new message below it. The phone number is requested **only in webhook mode**. With `UpdateMode: Polling` the MAX adapter declares no phone number and asks nothing: the signature is read from the raw webhook body, which polling does not have, so every contact would be rejected. A `Required` number then leaves MAX out of the sign-in page. ### WhatsApp Veriqa identifies a WhatsApp user by the business-scoped user ID (BSUID), not by the number. While WhatsApp sends the number with a message, the token carries `phone_number` with `phone_number_verified: true`, and nothing is asked. A user who has turned on a WhatsApp username may hide the number, and their messages come without it. When your application asks for the number of such a user, the request is sent, addressed by the BSUID: the `request_contact_info` interactive message, followed by a separate message with the second button — the request carries no buttons of its own. A contact card the user sends manually (`origin = other`) is rejected. The shipped Meta Cloud API provider sends the request, the Twilio provider does not; a WhatsApp provider of your own (`IWhatsAppProvider`) sends it only if it implements `SendContactRequestAsync` — otherwise the request is not delivered and the step waits as it does with no answer. ### Instagram The account sends one message: the text of the request with two quick-reply buttons under it. The first is drawn by Instagram and shares the phone number of the user's Instagram profile; the second carries the second action of the step. Instagram does not say that it has checked the number, so the token carries `phone_number` with `phone_number_verified: false`. A number the user types into the chat is not accepted — only the one the button sends. <Warning> **Not every user sees the button.** Instagram shows the button that shares the number only in its mobile app and only to a user whose profile has a phone number; on a computer it shows no quick replies at all — the second button included ([Meta: quick replies](https://developers.facebook.com/documentation/business-messaging/instagram-messaging/features/quick-replies)). The channel still counts as able to provide a number, so a `Required` number keeps Instagram in the sign-in page. A user who gets no button cannot share the number: an `Optional` number is skipped when its window runs out, and with a `Required` one the sign-in waits until the transaction expires or the user cancels it. There is no other way to hand the number over in this channel. </Warning> Because of that the product words the request for Instagram itself, telling the user what to do without a number in the profile: | Level | Address | Shipped text | | - | - | - | | `Optional` | `phone-number-request:BySurface:in-channel:ByChannel:instagram` | `Share your phone number by tapping the button. If your profile has no phone number, tap Skip.` | | `Required` | `phone-number-request-required:BySurface:in-channel:ByChannel:instagram` | `Share your phone number. If your profile has no phone number, you will need to add one and sign in again, or sign in via a messenger where your phone number is set.` | A wording of yours at the same address replaces the shipped one; keep the hint in it. ### Messenger The Page sends the same message as an Instagram account does: the text of the request with two quick-reply buttons under it — the one Messenger draws, which shares the phone number of the user's profile, and the one for the second action of the step. Messenger does not say that it has checked the number, so the token carries `phone_number` with `phone_number_verified: false`. A number the user types into the chat is not accepted — only the one the button sends. <Warning> **Not every user sees the button.** Messenger shows the button that shares the number only to a user whose profile has a phone number ([Meta: quick replies](https://developers.facebook.com/docs/messenger-platform/send-messages/quick-replies)). The channel still counts as able to provide a number, so a `Required` number keeps Messenger in the sign-in page. A user who gets no button cannot share the number: an `Optional` number is skipped when its window runs out, and with a `Required` one the sign-in waits until the transaction expires or the user cancels it. </Warning> The product words the request for Messenger itself, with the same two texts as for [Instagram](#instagram): the one for an `Optional` number at `phone-number-request:BySurface:in-channel:ByChannel:messenger`, the one for a `Required` number at `phone-number-request-required:BySurface:in-channel:ByChannel:messenger`. A wording of yours at the same address replaces the shipped one; keep the hint in it. ### Viber The bot sends the text of the request with two buttons under it: the `share-phone` button of the platform, which shares the phone number of the user's Viber account, and the button for the second action of the step. The number arrives as a contact message. Viber does not say whether a contact came from that button or was picked from the address book, and it does not state that the number belongs to the sender — so the token carries `phone_number` with `phone_number_verified: false`, and the channel never rejects a contact as somebody else's. A number the user types into the chat is not accepted — only a contact is. The `share-phone` button needs a Viber client with API level 3 or later; the request message tells the platform so, and an older client does not get it. The step has not been run against the live platform — see [Viber](/docs/guides/channels#viber). ### A channel connected over HTTP A channel whose adapter is an [external service](/docs/guides/custom-channel-adapter#an-http-channel-adapter-in-any-language) gives the number in the same two ways, and its registration says which — with `Http:Capabilities:PhoneNumber`: * **on request** (`OnRequest`) — Veriqa sends your adapter the request with the captions of both buttons; the adapter shows them the way its platform does and posts the shared number, or the second action, back as an event; * **automatically** (`Automatic`) — the adapter puts the number into the identity of the events it posts, and nothing is asked. A channel that declares neither gives no number and is left out of a sign-in that requires one. `phone_number_verified` is `true` only when both hold: the registration declares `Proof: Proven`, and the number came on request or the event itself states it verified. A registration without `Proven` never yields `true` — Veriqa lowers what the adapter states and writes a warning. The number is always in E.164: your adapter converts it to that form, Veriqa does not repair it. ### LINE, KakaoTalk, WeChat and Zalo — your integration These platforms give the phone number only to their own login or mini-app, which you register and the platform approves — not to a bot. Veriqa does not get the number there. If your application needs it from these users, obtain it in your own code through the platform's login and keep it on your side. ### Discord and Threema Neither platform gives a bot the user's phone number. ## Limitations * The window of an `Optional` number is kept in the memory of the instance that sent the request. If that instance stops before the window runs out, the step is not skipped, and the transaction waits until it expires. * The record that ties a chat to its waiting request is in-process by default. With more than one replica, move it to Redis together with the other channel stores ([channel stores](/docs/guides/storage#channel-stores-multiple-replicas)) — otherwise a number that arrives at another replica finds no request. * When one chat has two sign-ins waiting for a number, the number goes to the one whose request came last; the other waits for its window or until it expires. In Telegram the message that ends either of them removes the reply keyboard, whichever request it belongs to: the keyboard is one per chat. The request of the sign-in still waiting is then left without its buttons. * The buttons of the request are removed by the message that ends the sign-in — the receipt of its outcome (confirmed, declined or failed) or the notice that it expired — where the platform allows it; the channel sections above say how. Until that message they stay in the chat — also while a web step follows the phone step — and in WhatsApp, where a delivered message cannot be changed, they are not removed at all. Veriqa keeps the reference to the request with the record of the chat the sign-in was started from, one of the [channel stores](/docs/guides/storage#channel-stores-multiple-replicas): when that record is lost, the request stays and the expiry is not reported. A number shared from them after the step is over finds no waiting request and is not accepted, and the bot does not answer it; a tap on the second button is then an ordinary message to the bot. # Behind a reverse proxy Source: https://veriqa.app/docs/guides/reverse-proxy Which paths a reverse proxy in front of Veriqa has to pass, what the WebSocket of the sign-in page needs, and a ready NGINX configuration. A self-hosted Veriqa speaks HTTP and expects TLS to end at a proxy in front of it. This page lists what that proxy has to pass through. Trusting the proxy on the Veriqa side — the `ForwardedHeaders` section — is covered in [self-hosted, step 6](/docs/quickstart/self-hosted#6-put-it-behind-a-reverse-proxy). ## Give Veriqa a host name of its own The simplest and recommended layout is a dedicated host name — `auth.your-domain.com` — that sends every request to Veriqa. Nothing has to be listed, nothing collides with your application, and a path added by a later release works without touching the proxy. That address is also the `issuer` your applications validate tokens against: an instance has one issuer, so applications on different domains — a staging site and a production one, for example — all point at the same address rather than each proxying Veriqa under its own domain. If you mount Veriqa on your application's host name instead, you have two options. Pass every path of the table below: Veriqa then serves them from the root of the host name, so `/_content/` and `/hubs/` are taken on it. Or keep the root for your application and put the whole of Veriqa [under a path prefix](#under-a-path-prefix). ## Paths | Path prefix | Who calls it | What it is | | - | - | - | | `/.well-known/` | your application | Discovery document and signing keys | | `/connect/` | browser and your application | The OIDC endpoints and the sign-in page with its actions | | `/auth/` | browser | Pages outside the OIDC path: the confirmation page, the page that collects a missing phone number, email or profile field on the device that confirmed the sign-in, and the pages of the Email channel that email links lead to | | `/hubs/` | browser, WebSocket | Live status of the sign-in page and of the page that collects missing details (SignalR) | | `/api/transaction/` | browser and your application | Status polling when the WebSocket is not available; creating a confirmation transaction and reading its result | | `/_content/` | browser | Static files of the sign-in page | | `/api/channels/` | messaging platforms | Channel webhooks — must be reachable from the internet in `Webhook` mode | | `/r/` | browser | Short links of the hop mode — only when it is on; the prefix is `Veriqa:HopMode:HopPath` | | `/api/link-handle/` | your application | Exchange of a [link handle](/docs/scenarios/channel-linking#two-bots-handing-the-user-over-to-your-bot) for the identity of the signed-in user — only when a post-completion button carries `{handle}` | | `/api/collected-claims` | your application | Deletion of collected claims — only with a [collected claims store](/docs/guides/storage#collected-claims-store) | `/health/live`, `/health/ready` and `/health/channels` are for your own monitoring and need not be published. <Warning> A list limited to `/.well-known/`, `/connect/`, `/hubs/`, `/api/transaction/` and `/_content/` is enough for a plain sign-in and looks complete. It stops being enough the day you ask for a phone number or an email, enable the Email channel or switch a bot to `Webhook`: those pages and webhooks live under `/auth/` and `/api/channels/`. </Warning> ## WebSocket The sign-in page learns that the user has confirmed through the `/hubs/auth` hub. The proxy must let the connection upgrade — pass the `Upgrade` and `Connection` headers and use HTTP/1.1 towards Veriqa — and must not cut an idle connection sooner than a sign-in may last. When the upgrade does not get through, the page falls back to polling `/api/transaction/{id}/status`: the sign-in still completes, with a delay. ## NGINX ```nginx theme={null} # http context map $http_upgrade $connection_upgrade { default upgrade; '' close; } server { listen 443 ssl; http2 on; server_name auth.your-domain.com; ssl_certificate /etc/nginx/certs/server.crt; ssl_certificate_key /etc/nginx/certs/server.key; # Sign-in requests carry cookies; the 8k default answers 400 on a longer header large_client_header_buffers 4 32k; location / { proxy_pass http://127.0.0.1:8080; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; # WebSocket of the sign-in page proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_read_timeout 300s; } } ``` When Veriqa shares the host name with your application, keep the body of the `location` block and repeat it for each prefix of the table instead of `/`: ```nginx theme={null} location ~ ^/(\.well-known|connect|auth|hubs|api/transaction|api/channels|_content)/ { # the same proxy_* directives as above } ``` The repository contains a complete configuration with TLS settings and an HTTP-to-HTTPS redirect: `samples/docker-compose/nginx.conf` ([samples](/docs/samples)). ## Under a path prefix With a path prefix every address of Veriqa starts with it — `https://app.your-domain.com/auth/veriqa/connect/token` — and the root paths of the host name stay with your application. Set the prefix on the Veriqa host and repeat it in every public address you configure: | Key | Value | | - | - | | `Veriqa:PathBase` | `/auth/veriqa` | | `Veriqa:OpenIddict:Server:Issuer` | `https://app.your-domain.com/auth/veriqa` | | `Veriqa:Channels:Email:PublicBaseUrl`, `WebhookBaseUrl` of a channel in `Webhook` mode | the same address, with the prefix | `Veriqa:PathBase` starts with `/`, does not end with `/` and may have several segments. It is written as plain text: no `?`, `#`, `%`, `\`, whitespace, and no `.` or `..` segment. Any other value stops the host at startup; an empty one means the root. The prefix applies whichever way the proxy forwards the path — use one of the two blocks: ```nginx theme={null} # The path is passed as it is: Veriqa receives /auth/veriqa/connect/token location /auth/veriqa/ { proxy_pass http://127.0.0.1:8080; # the same proxy_* directives as above } # The prefix is trimmed: Veriqa receives /connect/token location /auth/veriqa/ { proxy_pass http://127.0.0.1:8080/; # the same proxy_* directives as above } ``` `https://app.your-domain.com/auth/veriqa/.well-known/openid-configuration` then returns the `issuer` and every endpoint with the prefix. A client that reads the metadata by RFC 8414 asks for it at the root of the host name, with the path of the issuer appended. Map that address: ```nginx theme={null} location = /.well-known/oauth-authorization-server/auth/veriqa { proxy_pass http://127.0.0.1:8080/auth/veriqa/.well-known/oauth-authorization-server; proxy_set_header Host $host; proxy_set_header X-Forwarded-Proto $scheme; } ``` This mapping is verified for a proxy that passes the path as it is. With a proxy that trims the prefix the target is the same address without `/auth/veriqa`; that variant has not been verified. What to keep in mind: * **A prefix that a path of Veriqa itself starts with** — `/auth`, `/api`, `/connect`, `/hubs`, `/_content`, `/health`, or the path of hop links (`/r` by default, `Veriqa:HopMode:HopPath`) — works only when the proxy passes the path as it is. Behind a trimming proxy such a prefix returns `404`. `/auth/veriqa` is not one of them. * **Requests that reach an instance directly go without the prefix** — a health probe asks `http://127.0.0.1:8080/health/live`. Veriqa cannot tell such a request from one the proxy trimmed and serves both, so closing the root of the instance from the outside is the job of the proxy. * **Several instances.** The proxy has to keep a browser on one instance (session affinity, `ip_hash` in NGINX): without it the hub of the sign-in page does not connect. The Email channel has a limit of its own — the multi-instance caveat in [Channel setup, Email](/docs/guides/channels#email). Neither depends on the prefix. * **Verified under a prefix:** sign-in by Email in both modes with its inbound webhook, the messenger webhook (`Webhook` mode), the [phone number](/docs/guides/phone-number) request, confirming an email address by a code or by a message the user sends during [claim enrichment](/docs/concepts/claim-enrichment), the tokens and `userinfo`, the form that collects a missing profile field, and a confirmation transaction created by your server. The messenger part was checked on Telegram only. `Veriqa:PathBase` is a setting of the shipped host. When Veriqa is embedded in your ASP.NET Core application, the prefix is the `UsePathBase` of that application, and discovery carries it. ## Other proxies Traefik, Caddy and IIS need the same three things: every path of the table, the `X-Forwarded-Proto` and `X-Forwarded-For` headers, and the WebSocket upgrade. Traefik and Caddy pass the upgrade without configuration; in IIS the WebSocket Protocol feature has to be installed. Under a path prefix, only the prefix needs to be passed instead of the paths in the table — see [Under a path prefix](#under-a-path-prefix). ## Check * `https://auth.your-domain.com/.well-known/openid-configuration` returns `https` URLs on your host name. `http` URLs or `invalid_request` mean the proxy is not trusted — [self-hosted, step 6](/docs/quickstart/self-hosted#6-put-it-behind-a-reverse-proxy). * On the sign-in page the browser's network tab shows `/hubs/auth` answered with `101 Switching Protocols`. * `Request Header Or Cookie Too Large` — raise `large_client_header_buffers`. # Integration scenarios Source: https://veriqa.app/docs/guides/scenarios Six applied Veriqa scenarios — a page each — plus what they share: the same authorize request and the modes that do not exist. Veriqa covers several applied scenarios, and each has its own page. What they share is the mechanism: the same authorize request, with the difference living entirely in the parameters you send and what you do with the result. <Note> **Scenarios have no API of their own.** Veriqa has no "scenario" parameter and no separate transaction type. That is why the recipes are given at the HTTP level — they work the same way on .NET, Node, Java and Python. </Note> ## The scenarios <CardGroup> <Card title="Passwordless sign-in" icon="qrcode" href="/docs/scenarios/passwordless-login"> A QR code on screen, confirmation in a messenger, registration in the same step. </Card> <Card title="Sign-in with channel linking" icon="link" href="/docs/scenarios/channel-linking"> One action, two results: the user signs in and the product gains a live channel. </Card> <Card title="Second factor and step-up" icon="shield-halved" href="/docs/scenarios/step-up"> A fresh confirmation before a sensitive operation, and the `auth_time` check. </Card> <Card title="Replacing SMS codes" icon="comment-sms" href="/docs/scenarios/sms-replacement"> The same flow with no per-message charge and with full authentication. </Card> <Card title="Leads without CAPTCHA" icon="file-signature" href="/docs/scenarios/lead-capture"> A verified contact and a live channel instead of a hand-typed address. </Card> <Card title="Account recovery" icon="key" href="/docs/scenarios/account-recovery"> A linked channel instead of a chain of emails and codes. </Card> </CardGroup> Confirming an operation **without creating a session** is a special case of the leads scenario; the mechanics are on [Confirmed form submission](/docs/integrations/confirmed-form). ## What you can pass to authorize | Parameter | Effect | | - | - | | `acr_values=channel:telegram` | Narrow the choice to a specific channel; several values separated by spaces let the user pick from those listed | | `scope=channel` | Issue `channel_type` and `channel_user_id` — see [channel linking](/docs/scenarios/channel-linking) | | `scope=profile` / `email` / `phone` | The corresponding claims, by standard OIDC rules | | `ui_locales` | The language of the sign-in window | ## What Veriqa does not have for this So you do not go looking for something that does not exist: * **no "scenario" parameter** in the authorize request — Veriqa has no notion of scenario types; * **there is a separate transaction entry point, but it is not about these scenarios.** `POST /api/transaction/confirmation` creates a confirmation transaction (the application authenticates with client credentials and receives a `transaction_id` and an entry point for the user), yet what it distinguishes is the declared action key and its typed slots, not scenarios. The scenarios on this page are built with a repeat authorize and work without it; * **no server-side freshness check** — the acceptable `auth_time` window is yours to set and verify; * **no client SDK or widget** — "your own button" is a link carrying the right parameters. ## Next <CardGroup> <Card title="OIDC + Veriqa explained" icon="key" href="/docs/concepts/oidc-explainer"> Where Veriqa sits inside the standard flow. </Card> <Card title="Channel setup" icon="comments" href="/docs/guides/channels"> Telegram, WhatsApp, other channels and Email. </Card> </CardGroup> # Storage Source: https://veriqa.app/docs/guides/storage Transaction store (InMemory / EF Core / Redis), the OpenIddict database (PostgreSQL / SQL Server) and plugging in another database. Veriqa has two independent stores: * **Transaction store** — the state of sign-in / confirmation transactions (Transaction Engine). * **OpenIddict database** — the OIDC server's clients and tokens. A third one is optional and off by default: the [collected claims store](#collected-claims-store), which remembers the claims Veriqa collected during a sign-in. ## Transaction store Configured in `ConfigureTransactionEngine`. Pick **one** store. ```csharp Program.cs theme={null} authServer.ConfigureTransactionEngine(te => { // Development / tests — in memory: te.UseInMemoryStore(); // Production — EF Core (the provider and connection string belong to the host): // te.UseEfCoreStore(ef => ef.UseNpgsql(connectionString, // npgsql => npgsql.MigrationsAssembly("Veriqa.Core.TransactionEngine.Migrations.PostgreSql"))); // …or on SQL Server: // te.UseEfCoreStore(ef => ef.UseSqlServer(connectionString, // sql => sql.MigrationsAssembly("Veriqa.Core.TransactionEngine.Migrations.SqlServer"))); // Redis (native TTL expiry): // te.UseRedisStore(redis => // { // redis.Configuration = "localhost:6379"; // redis.KeyPrefix = "veriqa:tx:"; // }); }); ``` | Store | Method | When | | - | - | - | | InMemory | `UseInMemoryStore()` | Development, tests and a [single-instance connector](#single-instance-connector-without-a-database) (state is lost on restart). | | EF Core | `UseEfCoreStore(ef => …)` | Production on a relational DB (PostgreSQL, SQL Server). | | Redis | `UseRedisStore(o => …)` | When you want a fast external store with native TTL. | <Note> `UseEfCoreStore` itself ships in a satellite package — the core does not drag EF Core in transitively. The EF Core provider (`UseNpgsql`, `UseSqlServer`) and its NuGet package belong to the host application — the core is provider-agnostic. The migrations assembly named in `MigrationsAssembly(...)` ships as one more package — without it the schema is never created: ```bash theme={null} dotnet add package Veriqa.Core.TransactionEngine.EntityFrameworkCore dotnet add package Veriqa.Core.TransactionEngine.Migrations.PostgreSql # or …Migrations.SqlServer ``` </Note> Migrations ship as one package per store and provider; the package name is also the value for `MigrationsAssembly(...)`: | Store | PostgreSQL | SQL Server | | - | - | - | | Transactions and the collected claims store | `Veriqa.Core.TransactionEngine.Migrations.PostgreSql` | `Veriqa.Core.TransactionEngine.Migrations.SqlServer` | | OpenIddict | `Veriqa.Core.AuthServer.Migrations.PostgreSql` | `Veriqa.Core.AuthServer.Migrations.SqlServer` | | Audit trail | `Veriqa.Core.AuditTrail.Migrations.PostgreSql` | `Veriqa.Core.AuditTrail.Migrations.SqlServer` | The audit trail keeps its migration history in a table of its own, so that it can share a database with the transaction store: next to `MigrationsAssembly(...)` in `UseEfCoreSink`, pass `MigrationsHistoryTable(AuditMigrationDefaults.MigrationsHistoryTable)`. Redis parameters (`RedisTransactionStoreOptions`): * `Configuration` — the connection string (for example, `localhost:6379`). * `KeyPrefix` — the transaction key prefix (default `veriqa:tx:`). **On the standalone image** the store is chosen by keys, not code: `Veriqa:TransactionEngine:Store:Provider` is `PostgreSQL`, `SqlServer` or `Redis`, and `Veriqa:TransactionEngine:Store:ConnectionString` holds the connection string of that store. On `Redis` it goes into `Configuration`, and `Veriqa:TransactionEngine:Store:KeyPrefix` sets `KeyPrefix`. Without a connection string the store stays in memory whatever the provider says. A Redis store lends nothing to the audit sink (see [Audit sink](/docs/quickstart/self-hosted#audit-sink)) and cannot hold the [collected claims store](#collected-claims-store); readiness gets a `redis-transactions` check, and a Redis unreachable at startup stops the container (unless the connection string sets `abortConnect=false`: then the container starts and only readiness reports the outage). ### A new sign-in closes the earlier ones When a user starts a sign-in again and reaches the channel with the same account, the earlier sign-ins of that account to the same application that are still waiting for an answer are closed — see [Starting the sign-in again](/docs/concepts/auth-flow#starting-the-sign-in-again). The transaction store is what finds them, so the upgrade touches each store in its own way: * **InMemory** — nothing to do. * **EF Core** — the migration `AddSignInSubjectFingerprint` adds one nullable column with a non-unique index to the transactions table. It ships in both migration packages and is applied like any other migration. Sign-ins written before it carry no fingerprint: they are not closed by a newer one and live until they expire. * **Redis** — next to the transactions, under the same `KeyPrefix`, the store keeps one set of transaction identifiers per tenant, application and channel account. The key holds a hash, never the user's identifier in the channel, and the set expires on its own. * **A store of your own** — `ITransactionStore` has a new member, `GetPendingSignInsAsync`, and it has no default implementation: your store stops compiling until it implements it. The member's obligations are described in the README of the `Veriqa.Core.TransactionEngine` package, section "Obligations of GetPendingSignInsAsync". ## Channel stores: multiple replicas The channel layer keeps several stores of its own. By default they are **in-process** — enough for a single replica, but not for two: | Port | What it holds | What breaks on a second replica | | - | - | - | | `IEmailActionTokenStore` | Email Pull action tokens (magic link) | a link opened on another replica finds no token — the sign-in never completes | | `IEmailPushCorrelationStore` | Email Push correlations and inbound-email deduplication | the email is not matched to its transaction; redelivery is not filtered out | | `IChannelPromptMessageStore` | the chat a transaction was started from, with the references to the in-channel prompt and to the [phone number request](/docs/guides/phone-number) sent into it | when the TTL expiry or a cancel on the sign-in page lands on another replica, the chat is not told: the prompt keeps its buttons and the phone number request stays | | `IPhoneRequestCorrelationStore` | which transaction the last [phone number request](/docs/guides/phone-number) in a chat belongs to | a shared number that lands on another replica finds no request — the sign-in does not get it | | `ILinkHandleStore` | fingerprints of the [link handles](/docs/scenarios/channel-linking#two-bots-handing-the-user-over-to-your-bot) put into post-completion buttons, each with its transaction, application and expiry | a handle presented to another replica is not found — the exchange answers `404` | | `IChannelStartCodeStore` | the short codes a sign-in is [started by](/docs/guides/custom-channel-adapter#start-by-a-code-—-when-the-link-cannot-be-relied-on) in a channel that declares it, each with its transaction and expiry | a code shown by one replica is not found by the replica that receives the message — the sign-in does not start | | store of the email completion (internal to the Email adapter) | codes and links of the emails that confirm an email address on the web step of the claims completion, and the counters of those emails | a code sent by one replica is refused by another; the email limits are counted per replica | If you run more than one replica, wire the Redis implementations of all these stores with a single call on the channel adapter builder: ```bash theme={null} dotnet add package Veriqa.Core.ChannelAdapter.Redis ``` ```csharp Program.cs theme={null} authServer.AddChannelAdapters(adapters => { adapters.AddEmail(); adapters.UseRedisChannelStores(redis => { // Empty — reuse an IConnectionMultiplexer already registered in the container // (for example, by the transaction engine satellite). redis.Configuration = "localhost:6379"; }); }); ``` Call order does not matter: the satellite registers the stores unconditionally and wins over the in-process defaults either way. Without a `UseRedisChannelStores` call everything stays in-process — the default implementations are never replaced behind your back. Parameters (`RedisChannelStoreOptions`): * `Configuration` — the connection string. **Leave it empty** to deliberately reuse an `IConnectionMultiplexer` registered in the container by someone else. Setting it while such a registration exists is refused: the host stops at startup with a configuration error naming both connections by their endpoints, logical database, TLS settings and sentinel master name (credentials are never printed) — otherwise the configured string would be silently ignored and the channel stores would land in someone else's Redis. The check runs when the host starts, so the order of the two registrations does not matter. * `PromptKeyPrefix` — prompt coordinate key prefix (default `veriqa:ch:prompt:`). * `EmailActionTokenKeyPrefix` — action token key prefix (default `veriqa:ch:email:token:`). * `EmailPushCorrelationKeyPrefix` — push correlation key prefix (default `veriqa:ch:email:corr:`). * `ProcessedMessageKeyPrefix` — processed-message key prefix (default `veriqa:ch:email:msg:`). * `EmailClaimCompletionKeyPrefix` — key prefix of the email completion store (default `veriqa:ch:email:claim:`). Addresses and channel accounts in its keys are SHA-256 fingerprints, and codes and link tokens are kept only as fingerprints. * `PhoneRequestCorrelationKeyPrefix` — phone number request key prefix (default `veriqa:ch:phone:`). The rest of the key is a SHA-256 hash of the chat, so the channel user identifier is not stored in plain text. * `LinkHandleKeyPrefix` — link handle key prefix (default `veriqa:ch:link:`). The rest of the key is a SHA-256 fingerprint of the handle; the handle itself is never stored. * `StartCodeKeyPrefix` — key prefix of the short codes a sign-in is started by (default `veriqa:ch:code:`). The rest of the key is a SHA-256 hash; the code itself is never part of a key. * `ProcessedMessageRetention` — how long a "message processed" record is kept (default 24 hours). <Note> Record lifetimes come from the entities themselves: a token and a correlation live exactly until their own `ExpiresAt`, and prompt coordinates until the longest possible transaction has expired plus the margin in which the expiry event is published (the coordinates are consumed by that event, and the background cleanup sweep publishes it after the transaction is gone). The single exception is `ProcessedMessageRetention` — a processed-message record has no owning entity. </Note> <Warning> Validate the Redis channel stores in your host once you turn them on. There is no fallback to the in-process stores: an unreachable Redis fails the operation instead of silently falling back to stores that break on a second replica. </Warning> **On the standalone image** the stores are switched on by one key, `Veriqa:ChannelStores:Redis:ConnectionString`; without it they stay in process. The parameters above are keys of the same section under the same names — `Veriqa:ChannelStores:Redis:LinkHandleKeyPrefix`, `Veriqa:ChannelStores:Redis:ProcessedMessageRetention` (`hh:mm:ss`) and so on; the connection string is `ConnectionString`, not `Configuration`. With the transaction store on `Redis` both share a single connection: set the same connection string in both keys. A different one stops startup with a message naming the two keys. Readiness gets a `redis-channel-stores` check unless the same Redis is already probed by another check, and a Redis unreachable at startup stops the container (unless the connection string sets `abortConnect=false`: then the container starts and only readiness reports the outage). ```bash theme={null} -e Veriqa__ChannelStores__Redis__ConnectionString="redis:6379" ``` ## Channel identities: nothing is stored out of the box The `IChannelIdentityRepository` port (transaction engine) persists a channel identity — channel user id, phone, email, display name — as an upsert keyed by the triple (`TenantId`, `ChannelType`, `ChannelUserId`). The contract provides neither deletion nor TTL: a record is kept indefinitely. **Veriqa ships no implementation of this port** and registers none. Without a registration of your own nothing is stored, and the sign-in still works: the resolved identity snapshot travels with the transaction. Keeping that data (PII, kept indefinitely) is your decision — register an implementation and every sign-in becomes an upsert: ```csharp Program.cs theme={null} builder.Services.AddSingleton<IChannelIdentityRepository, MyChannelIdentityRepository>(); ``` The lifetime is yours to choose: the resolver asks for the port once per operation from a scope of its own, so a `Scoped` implementation over a `DbContext` is as safe as a `Singleton` one. A reference in-memory implementation lives in the `samples/dotnet/inproc/login` sample. The full picture of what is kept where and for how long — this port included — is in [Security and data handling](/docs/guides/data-handling#retention). ## Collected claims store When a sign-in lacks a claim the application requires, Veriqa collects it itself — a phone number requested in the channel, an email confirmed by a code or a link, a field of its form (see [Claim enrichment](/docs/concepts/claim-enrichment)). Without a store all of that lives only as long as the transaction, and the next sign-in asks again. The collected claims store keeps it keyed by the tenant and the `sub`: what Veriqa collected, the `Optional` claims the user declined, the time of the last sign-in that read the records and the `sub` an application received when its claims mapper changed it. Values the channel itself supplies are not stored. **Nothing is stored until you turn it on.** On the standalone server that is one key, `Veriqa:CollectedClaims:Store:Enabled`: ```json appsettings.json theme={null} { "Veriqa": { "CollectedClaims": { "Store": { "Enabled": true } } } } ``` The store then lives in the transaction store's database — the same provider, connection string and migrations assembly — with a migration history table of its own. `Enabled: true` without `Veriqa:TransactionEngine:Store:ConnectionString` stops startup with a message naming both keys, and so does `Enabled: true` with `Veriqa:TransactionEngine:Store:Provider` = `Redis`: Redis has no relational database to keep the store in. In the embedded mode the store is a registration of its own, and its database is yours to choose: ```csharp Program.cs theme={null} authServer.ConfigureTransactionEngine(te => { te.UseEfCoreStore(ef => ef.UseNpgsql(connectionString, npgsql => npgsql.MigrationsAssembly("Veriqa.Core.TransactionEngine.Migrations.PostgreSql"))); te.UseEfCoreCollectedClaimsStore(ef => ef.UseNpgsql(connectionString, npgsql => npgsql .MigrationsAssembly("Veriqa.Core.TransactionEngine.Migrations.PostgreSql") .MigrationsHistoryTable(CollectedClaimsMigrationDefaults.MigrationsHistoryTable))); }); ``` The migrations ship in the transaction store's migration packages as a second context, `CollectedClaimsDbContext`, and are applied at startup like the transaction store's. Keep `MigrationsHistoryTable(CollectedClaimsMigrationDefaults.MigrationsHistoryTable)` — the store may share a database with the transactions, and a shared history table would mix the two sets of migrations. Your own implementation of `ICollectedClaimsStore` is plugged in with `te.UseCollectedClaimsStore<TStore>()` and gets everything below — the endpoint, the host call and the retention sweep — exactly as the EF Core one does. <Warning> The store is **one per tenant, shared by all its applications**: a value collected for one application is issued to another of the same tenant when it passes that application's rules, and a deletion made at the request of one application erases the records for all of them. </Warning> ### Deleting by `sub` When a user deletes their account or asks for their data to be erased, you, as the data controller, delete everything the store keeps about them — all their records and the subjects issued to applications. One operation has two entry points; both are idempotent, so a `sub` with nothing stored is a success. **The server endpoint** — for an application that does not embed Veriqa as a .NET library: ```http theme={null} DELETE /api/collected-claims?sub=<sub>[&client_id=<application>] Authorization: Bearer <client credentials token> ``` The route exists only when a collected claims store is registered. The caller is a server client with `AllowClientCredentials` and `AllowCollectedClaimsDeletion` (see [Clients](/docs/reference/configuration#clients); the flag without `AllowClientCredentials` stops startup), and its token comes from `grant_type=client_credentials` — as for the [confirmation API](/docs/reference/confirmation-api#2-get-a-token). `sub` is passed in the form the application received it: the form of the application that `client_id` names, or of the calling client when the parameter is absent. The subjects issued to that application are searched within the caller's tenant; a `sub` not found among them is taken as the core one. The endpoint has no rate limit of its own — grant the flag to one of your own server clients only. | Condition | Response | | - | - | | deleted, or nothing was stored | `204 No Content` | | no token, or an invalid one | `401` | | a user's token, or a client without `AllowCollectedClaimsDeletion` | `403` | | `sub` missing, empty or stated twice; `client_id` empty or stated twice | `400` problem, `title` = `oidc_request_invalid` | | the store or the tenant resolution failed | `503` problem, `title` = `collected_claims_store_unavailable`. A failed tenant resolution deletes nothing. When `sub` stands for several core subjects, those deleted before the store failed stay deleted and recorded in the audit trail; repeating the request completes the deletion | **The host call** — for an application that embeds Veriqa: ```csharp theme={null} // tenantId: null — the implicit tenant of a self-hosted installation. // applicationId: the client_id the subject was issued to; null — subject is the core sub. await deletion.DeleteBySubjectAsync(tenantId: null, applicationId: "shop", subject: sub); ``` `ICollectedClaimsDeletion` is registered together with the store; without a store it is not in the container. Each deletion writes a `CollectedClaimsDeleted` record to the [audit trail](/docs/quickstart/self-hosted#audit-sink) when the trail is on: the actor is the calling client for the endpoint and `system` for the host call and the sweep; the target is the masked core `sub`; the metadata carry the tenant, the client and the basis — `endpoint`, `host_call` or `retention`. Claim values never reach the record. With the trail off the deletion works and writes nothing. After a deletion the next sign-in of that user works like the first one: Veriqa asks again for what is missing, a previously declined `Optional` claim included. ### Deleting unused records Records of a `sub` nobody has signed in with for a given number of days can be deleted automatically. The clock starts at the last sign-in that read the records, not at the time a value was collected. Off by default: ```json appsettings.json theme={null} { "Veriqa": { "ClaimCompletion": { "CollectedClaims": { "RetentionDays": 365 } } } } ``` `0` turns it off; a negative value stops startup. At the tenant level the member `ClaimCompletionCollectedClaimsRetentionDays` replaces the global value, so the sweep can run for one tenant only (`ClaimCompletion.CollectedClaimsRetentionDays` in the [declaration catalog](/docs/reference/configuration#keys-in-the-declaration-catalog)). The sweep runs at startup and then hourly, in batches of 100; a sign-in that reads the records between the selection and the deletion keeps them. Each deleted `sub` gets an audit record with the basis `retention`. ## OpenIddict database The OIDC server's clients and tokens are stored separately and configured with the `Veriqa:OpenIddict:Database` section: ```json appsettings.json theme={null} { "Veriqa": { "OpenIddict": { "Database": { "Provider": "PostgreSQL", "ConnectionString": "Host=…;Database=…;Username=…;Password=…" } } } } ``` | Provider | Purpose | | - | - | | `InMemory` | Development, tests and a [single-instance connector](#single-instance-connector-without-a-database). No `ConnectionString` needed. | | `PostgreSQL` | Production provider. `ConnectionString` required. | | `SqlServer` | Production provider. `ConnectionString` required. | <Warning> **This choice has no default.** An empty section is not "`InMemory` by default", it is a choice nobody made: outside the `Development` environment the application fails to start (the startup check refuses and names the fix); in `Development` it starts with a `Warning`. In the embedded mode the section does not select the provider — there the choice is made in the host's code: `authServer.UseOpenIddictDatabase(ef => ef.UseNpgsql(connectionString))` for a relational provider, or `authServer.UseInMemoryOpenIddictStore()` for the volatile one. </Warning> <Warning> In production, initialize the relational DB with **migrations**, not schema auto-creation — that keeps a schema history and predictable upgrades (see [Hardening](/docs/guides/hardening)). </Warning> ## Single-instance connector without a database When Veriqa is attached to another identity provider — [Keycloak](/docs/integrations/apps#keycloak) or your own — it takes part in the sign-in and in nothing after it: the authorization request, the confirmation in the channel, the code exchange and the userinfo call right after it. The session and its refresh tokens then belong to that provider, and Veriqa keeps no user accounts either (see [Channel identities](#channel-identities-nothing-is-stored-out-of-the-box)). Such a deployment can run as a single instance with every store in memory. In self-hosted mode: ```json appsettings.json theme={null} { "Veriqa": { "OpenIddict": { "Database": { "Provider": "InMemory" } } } } ``` * leave `Veriqa:TransactionEngine:Store:ConnectionString` unset — the transactions stay in memory; * leave `Veriqa:Logging:Mode` at its default, or at anything other than `Audit`; * declare the client without `AllowRefreshTokens` (off by default) and without `offline_access`. In the embedded mode the same choice is `authServer.UseInMemoryOpenIddictStore()` plus `te.UseInMemoryStore()`. The host starts with a `Warning` that the OpenIddict store is volatile — for this setup that is expected. What a restart costs: | What | After a restart | | - | - | | Sign-ins in progress | Fail; the user starts the sign-in again | | Sessions in your identity provider | Unaffected — they never lived in Veriqa | | Access tokens issued before the restart | Rejected: an access token is an opaque reference to a record in the OpenIddict store, and the record is gone — irrelevant when your provider calls userinfo right after the code exchange | | Refresh tokens | None — the client does not get them | A deploy, an image update and a container restart are all restarts: each of them costs the first row. <Warning> **Pick a database instead when:** * Veriqa is the only identity provider of an application that keeps sessions with refresh tokens (`AllowRefreshTokens`) — its users sign in again after every restart; * you run more than one instance — each one has stores of its own (see [Running more than one instance](/docs/quickstart/self-hosted#running-more-than-one-instance)); * `Veriqa:Logging:Mode` is `Audit` — an in-memory trail keeps the host from starting outside `Development` (see [Audit sink](/docs/quickstart/self-hosted#audit-sink)). </Warning> ## Another database or a model change The standalone image ships PostgreSQL and SQL Server (and Redis for the transaction store). Anything else is a choice your own host makes in the embedded mode — no change to Veriqa is needed. ### Your own migrations assembly The four EF Core contexts are public: `TransactionDbContext` (`Veriqa.Core.TransactionEngine.Store.EfCore`), `CollectedClaimsDbContext` (`Veriqa.Core.TransactionEngine.CollectedClaims.EfCore`), `AuditDbContext` (`Veriqa.Core.AuditTrail.Store.EfCore`) and `OpenIddictDbContext` (`Veriqa.Core.AuthServer.Data`). They are anchors for migrations, not a data access API — their tables stay internal. For a database Veriqa ships no migrations for: 1. Create a class library that references your EF Core provider, `Microsoft.EntityFrameworkCore.Design` and the package holding the context. 2. Add an `IDesignTimeDbContextFactory<T>` for each context — `dotnet ef` builds the context through it. 3. Run `dotnet ef migrations add Initial --project <your library> --context <context>`, and name the library in `MigrationsAssembly(...)` at runtime. ```csharp TransactionDbContextDesignTimeFactory.cs theme={null} public sealed class TransactionDbContextDesignTimeFactory : IDesignTimeDbContextFactory<TransactionDbContext> { public TransactionDbContext CreateDbContext(string[] args) { var options = new DbContextOptionsBuilder<TransactionDbContext>() // UseXxx — your EF Core provider. .UseXxx(Environment.GetEnvironmentVariable("TRANSACTION_DB_CONNECTION_STRING"), db => db.MigrationsAssembly(typeof(TransactionDbContextDesignTimeFactory).Assembly.GetName().Name)) // Only if you customize the model — the same replacement as at runtime (see below). .ReplaceService<IModelCustomizer, VeriqaModelCustomizer>() .Options; return new TransactionDbContext(options); } } ``` For `AuditDbContext`, add `MigrationsHistoryTable(AuditMigrationDefaults.MigrationsHistoryTable)` next to `MigrationsAssembly(...)` — in the factory and at runtime alike; for `CollectedClaimsDbContext`, `MigrationsHistoryTable(CollectedClaimsMigrationDefaults.MigrationsHistoryTable)` in the same way. ### Changing the model A schema, a collation, a column type — change the model with the standard EF Core `IModelCustomizer`, not by editing Veriqa's contexts. Derive from `RelationalModelCustomizer`: ```csharp VeriqaModelCustomizer.cs theme={null} public sealed class VeriqaModelCustomizer(ModelCustomizerDependencies dependencies) : RelationalModelCustomizer(dependencies) { public override void Customize(ModelBuilder modelBuilder, DbContext context) { // Runs the context's OnModelCreating: Veriqa's entities and configuration. base.Customize(modelBuilder, context); // Your changes on top — for example, a schema of its own: modelBuilder.HasDefaultSchema("veriqa"); } } ``` Plug it in with `ReplaceService<IModelCustomizer, …>()` inside the same delegate that picks the provider: ```csharp Program.cs theme={null} authServer.ConfigureTransactionEngine(te => te.UseEfCoreStore(ef => ef .UseXxx(connectionString, db => db.MigrationsAssembly("MyCompany.Veriqa.Migrations")) .ReplaceService<IModelCustomizer, VeriqaModelCustomizer>())); authServer.UseOpenIddictDatabase(ef => ef .UseXxx(connectionString, db => db.MigrationsAssembly("MyCompany.Veriqa.Migrations")) .ReplaceService<IModelCustomizer, VeriqaModelCustomizer>()); services.AddVeriqaAuditTrail(audit => audit.UseEfCoreSink(ef => ef .UseXxx(connectionString, db => { db.MigrationsAssembly("MyCompany.Veriqa.Migrations"); db.MigrationsHistoryTable(AuditMigrationDefaults.MigrationsHistoryTable); }) .ReplaceService<IModelCustomizer, VeriqaModelCustomizer>())); ``` For the OpenIddict database, Veriqa applies its own OpenIddict setup before calling your delegate, so the replacement you make there is the one that stays. A changed model no longer matches the shipped migrations, even on PostgreSQL or SQL Server — generate your own assembly for it as described above. <Warning> **The design-time factory must make the same replacement.** `dotnet ef` builds the model through the factory, the application through your delegate. If `ReplaceService<IModelCustomizer, …>()` is missing on either side, the migration describes one model and the application runs another. </Warning> <Warning> **Keep the `base.Customize` call.** It is what runs the context's `OnModelCreating`. Without it the model has neither the OpenIddict entities nor Veriqa's own configuration of its tables. </Warning> ## Publishing transaction events In-process delivery to `ITransactionEventHandler` (see [Configuration](/docs/guides/customization#transaction-event-handler)) is part of the engine and needs no call of its own: the audit trail, the real-time sign-in push and the prompt cleanup receive events in every deployment. Publishing events **out of the process** is a separate, additive choice of a transport — also made in `ConfigureTransactionEngine`: ```csharp Program.cs theme={null} authServer.ConfigureTransactionEngine(te => { te.UseEfCoreStore(ef => ef.UseNpgsql(connectionString)); // Optional — parameters of the in-process dispatch (queue capacity): // te.UseChannelsPublisher(ch => ch.Capacity = 1000); // Outward — RabbitMQ (for distributed systems and external consumers). // Additive: the in-process handlers keep receiving their events. // te.UseRabbitMqPublisher(mq => // { // mq.ConnectionString = "amqp://user:pass@host:5672/"; // mq.ExchangeName = "veriqa.transactions"; // }); }); ``` `RabbitMqEventPublisherOptions`: `ConnectionString` (required, `amqp://…`), `ExchangeName` (default `veriqa.transactions`), plus queue and reconnection parameters (`QueueCapacity`, `MaxReconnectAttempts`, `ReconnectBaseDelaySeconds`). **On the standalone image** the publisher is switched on by `Veriqa:Events:RabbitMq:ConnectionString`; the other parameters are keys of the same section under the same names (`Veriqa:Events:RabbitMq:ExchangeName` and so on). Without the connection string there is no publisher. With it, readiness gets a `rabbitmq-events` check, and a broker unreachable at startup stops the container. Reliability of what leaves the process is the reliability of the chosen bus — no more and no less. Veriqa builds no delivery guarantee of its own on top of the transport: with the in-process channel an event can be lost on a crash, and with a message queue delivery is exactly as reliable as that queue makes it. ## Next steps <CardGroup> <Card title="Production hardening" icon="shield-check" href="/docs/guides/hardening"> Migrations, secrets, key rotation. </Card> <Card title="Configuration & customization" icon="sliders" href="/docs/guides/customization"> Channels, design, localization, extension points. </Card> </CardGroup> # Supported channels Source: https://veriqa.app/docs/guides/supported-channels Which messengers and mail Veriqa signs users in through, today and next: the claims each channel adds, whether it asks for confirmation in the chat, and whether the user sends a secret message. <Note> The same catalog is on [veriqa.app/channels](https://veriqa.app/channels). The two are kept in sync, and a difference between them is a defect, not two opinions. </Note> The user confirms a sign-in in a messenger or a mailbox they already use — with nothing extra to install. Your application receives ordinary OpenID Connect tokens whichever channel was used. Every channel issues the base claims `channel_type`, `channel_user_id` and `name` — a card lists what the channel adds on top. For channels not shipped yet it shows what the platform's API allows. Besides the claims, a channel's description uses two terms: secret message and confirmation in the channel. ## Secret message A secret message is the message with the sign-in code that the user sends to the channel themselves after tapping the link or scanning the QR code. Veriqa fills it in, the messenger shows it pre-filled, and the user only taps Send. Not every channel needs one. Examples: * Telegram and MAX: not needed — the messenger opens the bot and hands it the code on its own. * WhatsApp: needed, for stronger security — the pre-filled message goes out with one tap. * Email: the same with a pre-filled letter — the **one-tap email** (`Push` mode) — or optionally a classic magic link. ## Confirmation in the channel Confirmation in the channel is a feature where the bot asks right in the chat whether to let the sign-in through — which app and from which device — with Confirm and Decline buttons. It can be switched to automatic mode, and then the sign-in goes through without an extra request: the `LoginConfirmation.Mode` key in the [configuration reference](/docs/reference/configuration). Examples: * Telegram: the question comes as soon as the user opens the link. * WhatsApp: the question comes in reply to the secret message. * Email: no question in the channel — optionally it is asked on a page in the browser. ## Built in These ship with Veriqa — enable the ones you need in configuration. <CardGroup> <Card title="Telegram"> **Claims on top of the base:** `given_name`, `family_name`, `preferred_username`, `locale`, `picture` **Phone number:** `phone_number` and `phone_number_verified` on request — the user shares the number with a button in the chat ([phone number](/docs/guides/phone-number)) **Confirmation in the channel:** yes **Secret message:** not needed </Card> <Card title="WhatsApp"> **Claims on top of the base:** `given_name`, `preferred_username` (if the user has turned on a WhatsApp username) **Phone number:** `phone_number` and `phone_number_verified` automatically when WhatsApp sends the number with the message; when the user hides it behind a username — on request, the user shares the number with a button in the chat ([phone number](/docs/guides/phone-number)) **User identifier:** `sub` and `channel_user_id` carry the business-scoped user ID (BSUID) — unique per Meta business portfolio and new when the user changes the number; `preferred_username` is a hint, not the identity **Confirmation in the channel:** yes **Secret message:** needed — one tap </Card> <Card title="WhatsApp through Twilio"> The same WhatsApp channel, delivered through a Twilio account instead of the Meta WhatsApp Business Platform — one configuration key picks the provider ([delivery through Twilio](/docs/guides/channels#delivery-through-twilio)). **Claims on top of the base:** `given_name`, `preferred_username` (if the user has turned on a WhatsApp username) **Phone number:** `phone_number` and `phone_number_verified` automatically when WhatsApp sends the number with the message; a number hidden behind a username is not asked for — the request with a button in the chat is sent by the Meta Cloud API provider only ([phone number](/docs/guides/phone-number)) **User identifier:** `sub` and `channel_user_id` carry the business-scoped user ID (BSUID), as with the Meta provider; `preferred_username` is a hint, not the identity **Confirmation in the channel:** yes — two quick-reply buttons **Secret message:** needed — one tap </Card> <Card title="MAX"> **Claims on top of the base:** `given_name`, `family_name`, `preferred_username`, `locale`, `picture` **Phone number:** `phone_number` and `phone_number_verified` on request — the user shares the number with a button in the chat; webhook mode only ([phone number](/docs/guides/phone-number)) **Confirmation in the channel:** yes **Secret message:** not needed </Card> <Card title="Instagram"> The user confirms in Instagram Direct, in a conversation with your Instagram professional account. Signing in users outside your own team needs access granted by Meta ([channel setup](/docs/guides/channels#instagram)). **Claims on top of the base:** `preferred_username`, `picture` **Phone number:** `phone_number` on request, with `phone_number_verified: false` — the user shares the number of their Instagram profile with a button in the chat; the button shows only in the Instagram mobile app and only if the profile has a number ([phone number](/docs/guides/phone-number#instagram)) **Confirmation in the channel:** yes **Secret message:** not needed </Card> <Card title="Messenger"> The user confirms in Messenger, in a conversation with your Facebook Page. Signing in users outside your own team needs access granted by Meta ([channel setup](/docs/guides/channels#messenger)). **Claims on top of the base:** `given_name`, `family_name`, `picture`, `locale` — when Meta lets the app read the user's profile **Phone number:** `phone_number` on request, with `phone_number_verified: false` — the user shares the number of their profile with a button in the chat; the button shows only if the profile has a number ([phone number](/docs/guides/phone-number#messenger)) **Confirmation in the channel:** yes **Secret message:** not needed </Card> <Card title="WeChat"> The user scans the QR code with WeChat and confirms in the chat with your Official Account. The channel needs a verified Service Account ([channel setup](/docs/guides/channels#wechat)). **Claims on top of the base:** none — only the base ones; `name` holds the OpenID **Confirmation in the channel:** yes **Secret message:** not needed </Card> <Card title="Viber"> The user confirms in Viber, in a conversation with your bot. The adapter has been run only against a mock of the Bot API and has not been verified on the live Viber platform. Since February 5, 2024, Viber issues new bots on commercial terms only — get the bot and its token from Viber before a deployment ([channel setup](/docs/guides/channels#viber)). **Claims on top of the base:** `picture`, `locale` **Phone number:** `phone_number` on request, with `phone_number_verified: false` — the user shares the number with a button in the chat ([phone number](/docs/guides/phone-number#viber)) **Confirmation in the channel:** yes **Secret message:** not needed </Card> <Card title="Email"> **Claims on top of the base:** `email`, `email_verified`, `preferred_username`, `veriqa_channel`, `veriqa_email_mode` **Confirmation in the channel:** no — optionally in the browser **Secret message:** needed — one tap, or a classic magic link **Two modes:** the **one-tap email** (the user sends a pre-filled letter, so nothing has to be delivered to them and no link can be spent by a mail gateway) and the classic **magic link**, which is on by default. What each one gives you — [choosing between them](/docs/guides/channels#which-mode-to-choose). **Receiving the one-tap email:** through the webhook of an inbound mail provider, or without one — Veriqa reads an ordinary mailbox itself ([one-tap email over IMAP](/docs/guides/channels#one-tap-email-over-imap)). </Card> </CardGroup> ## Any channel over HTTP Connect a channel Veriqa does not ship — your own bot, a corporate messenger, a regional platform — as an external service in any language. Veriqa talks to it over HTTP, and the platform's keys stay with your service. It is also one way to bring a bot you already run into the Veriqa flow — without publishing a new one; the other ways are compared in [You already have a bot](/docs/guides/existing-bot). * **Claims on top of the base:** whatever your adapter returns * **Confirmation in the channel:** declared by the adapter * **Secret message:** declared by the adapter <CardGroup> <Card title="WhatsApp through Baileys"> A working reference adapter in Node that links a WhatsApp number as a companion device, the way WhatsApp Web does — no Meta Business account and no provider. Meant for debugging WhatsApp scenarios, for small teams and for your own use. **Risk:** it runs on an unofficial WhatsApp Web client, and WhatsApp may ban the linked number — link a separate number you can afford to lose. **Claims on top of the base:** `phone_number`, `given_name` **Confirmation in the channel:** yes — a poll with two options **Secret message:** needed — one tap The sample: `samples/node/custom-channel-whatsapp-baileys/`. </Card> </CardGroup> How to write one, with reference adapters in Python and Node — [An HTTP channel adapter in any language](/docs/guides/custom-channel-adapter#an-http-channel-adapter-in-any-language). ## Pseudo-channels: Google, Apple and Huawei A sign-in at an identity provider, wrapped into the shape of a channel. The screen shows a QR code, the user signs in to their Google, Apple or Huawei account on their own phone and confirms on a page there, and the sign-in completes on the screen. This is not a delivery channel: there is no chat or mailbox behind it, so no message can be sent to the user "in the channel", and the question "is it you signing in?" is asked by a page on the phone. A pseudo-channel works under [hop mode](/docs/concepts/hop-mode). Where it helps: signing in with Google on a device with no keyboard, such as a TV; on an operating system outside Google's ecosystem and in a native app; confirming actions; and wherever typing a Google or Apple password is risky — on a shared or someone else's computer, for instance. A user who has none of the messengers can sign in too, with the account their phone already has. <CardGroup> <Card title="Google"> **Claims on top of the base:** `email`, `email_verified`, `given_name`, `family_name`, `picture`, `locale` **Confirmation in the channel:** yes — on a page on the phone **Secret message:** not needed **Delivery:** a sample .NET assembly in the host process — `samples/dotnet/pseudo-channels` </Card> <Card title="Apple"> **Claims on top of the base:** `email`, `email_verified` **Confirmation in the channel:** yes — on a page on the phone **Secret message:** not needed **Delivery:** a sample .NET assembly in the host process — `samples/dotnet/pseudo-channels` </Card> <Card title="Huawei"> **Claims on top of the base:** `email`, `email_verified`, `locale` **Confirmation in the channel:** yes — on a page on the phone **Secret message:** not needed **Delivery:** upcoming — a separate service next to Veriqa, connected as an HTTP channel, together with Google and Apple; see [Coming next](/docs/releases/next) </Card> </CardGroup> ## Separate adapters: the `channel-adapters` repository Adapters for LINE, Slack, Microsoft Teams, Discord, Zalo, KakaoTalk and Threema are already implemented, but they live outside the core, in the `channel-adapters` repository (see the [source code](https://veriqa.app/source)). Each one is a separate .NET assembly, `Veriqa.ChannelAdapter.<Channel>`, built on the core contracts package. The host that embeds Veriqa registers it together with a channel declaration for the core (`CoreChannelDeclaration`); without the declaration the sign-in page does not offer the channel. The README of each adapter covers the sign-in flow, the platform setup and a minimal host; the ones run live also have a section on what the live platform revealed. "Live run" means a sign-in on the real platform with a real bot or app; "against an API stand-in" means a full sign-in against a local double of the platform API, written from its documentation. <CardGroup> <Card title="LINE"> **Claims on top of the base:** `picture`, `locale` **Start:** a `line.me` link with the text pre-filled **Tested:** against an API stand-in </Card> <Card title="Slack"> **Claims on top of the base:** `given_name`, `family_name`, `preferred_username`, `email`, `picture`, `locale` **Start:** the `/veriqa <code>` command in Slack **Tested:** live run — both confirm and decline </Card> <Card title="Microsoft Teams"> **Claims on top of the base:** `given_name`, `family_name`, `preferred_username`, `email` **Start:** a deep link to a chat with the bot **Tested:** against an API stand-in </Card> <Card title="Discord"> **Claims on top of the base:** `preferred_username`, `picture`, `locale` **Start:** a QR code or a link to the OAuth2 authorization page, or the `/veriqa code:<code>` command; the bot has to share a server with the user **Tested:** live run — both confirm and decline, with claims from the real profile </Card> <Card title="Zalo"> **Claims on top of the base:** `picture` **Start:** the text `auth_<code>` sent to the bot **Tested:** against an API stand-in </Card> <Card title="KakaoTalk"> **Claims on top of the base:** `email` **Phone number:** not through the bot — only with your own Kakao Login or Kakao Sync integration ([phone number](/docs/guides/phone-number)) **Start:** the text `auth_<code>` sent to the bot **Tested:** against an API stand-in </Card> <Card title="Threema"> **Claims on top of the base:** none — only the base ones **Start:** a `threema.id` link **Tested:** against an API stand-in </Card> </CardGroup> Slack, Zalo and KakaoTalk have no link that carries the transaction to the bot, so the sign-in page does not offer them by itself yet: the user gets the sign-in code some other way. The same repository has relays for Instagram and Messenger through the SendPulse and UChat chatbots: inside the host process, or as a separate service connected as an HTTP channel. # Troubleshooting Source: https://veriqa.app/docs/guides/troubleshooting A catalog of integration failures: the symptom, the cause and what to do. Look up a symptom by its error text — most of them mean something other than what they appear to say. ## Failures at `/connect/authorize` and `/connect/token` | Symptom | Cause and what to do | | - | - | | `invalid_request` · `ID2029` — `The mandatory 'code_challenge' parameter is missing` | PKCE is mandatory for this client because it is registered as public. Either add PKCE or make the client confidential — off-the-shelf CMS plugins usually need the latter. | | `invalid_request` · `ID2054` | The same thing, in newer wording. | | `invalid_request` · `ID2032` — `The specified 'code_challenge_method' is not supported` | `code_challenge_method=plain` was sent. Only `S256` is supported. **The same `ID2032` covers several "not supported" refusals — go by `error` and `error_description`, not by the number.** | | `unsupported_response_type` · `ID2032` — `The specified 'response_type' is not supported` | The client is asking for the implicit flow (`response_type=token` or `id_token`). Veriqa accepts `code` only: implicit was removed in OAuth 2.1 as unsafe. The classic case is Odoo's stock `auth_oauth` module; what to install instead is on the [Veriqa in business systems](/docs/integrations/apps) page. | | `invalid_target` · `ID2190` — `One of the specified 'resource' parameters is invalid` | The client sends a `resource` parameter (RFC 8707 resource indicators). Veriqa registers none, so **any** value is rejected. Moodle's `auth_oidc` plugin, written for Microsoft Entra ID, behaves this way — see [Veriqa in business systems](/docs/integrations/apps). For a client that cannot be told to stop, enable the per-client quirk [`drop-resource-parameter`](/docs/guides/client-compatibility). | | `invalid_request` · `ID2087` — `Multiple client credentials cannot be specified` | The client sends its credentials **both** in an `Authorization: Basic` header **and** in the body, while RFC 6749 §2.3.1 allows one method. A Joomla classic: clear the "In Header" checkbox. | | The token endpoint rejects a CMS plugin's request | An extra `scope` in the `grant_type=authorization_code` request. The explanation and two ways to fix it are on the [WordPress](/docs/integrations/wordpress) page. | | `invalid_grant` · `ID2001` | The authorization code is corrupted or expired. | | `400` · `ID2083` — `This server only accepts HTTPS requests` | The issuer is served over `http`. Note that a `redirect_uri` over `http` is still accepted — this symptom is about the issuer address specifically. | | `access_denied` at the `redirect_uri` | The user declined the confirmation or pressed **Cancel sign-in**, or Veriqa refused the sign-in by a rule of its own: a claim the client requires could not be obtained from the user, or the user exceeded the limit of sign-in requests. Not an integration error; an immediate retry of the same request gets the same response. An ASP.NET Core client on `AddOpenIdConnect` responds to it with an HTTP 500 unless `AccessDeniedPath` is set — see [step 5 of the .NET quickstart](/docs/quickstart/dotnet#5-wire-up-the-client-application). The callback of the [Node.js](/docs/quickstart/node#5-handle-the-return) and [Python](/docs/quickstart/python#4-define-the-sign-in-routes) quickstarts catches the library's error for it; Spring `oauth2Login()` sends the user to `/login?error` by itself. | | `server_error` at the `redirect_uri` | The sign-in started and then failed on the Veriqa side: the channel could not carry the confirmation out, the user's identity could not be resolved, or the sign-in could not be finalized. The response carries `error`, `state` and `iss` and no `error_description`; the reason is in the Veriqa log and in the audit trail, under the transaction. Handle it as a failed sign-in and offer to start again. An ASP.NET Core client on `AddOpenIdConnect` raises `OnRemoteFailure` for it and answers with an HTTP 500 unless that event is handled — `AccessDeniedPath` covers `access_denied` only; see [step 5 of the .NET quickstart](/docs/quickstart/dotnet#5-wire-up-the-client-application). | ## Failures during confirmation | Symptom | Cause and what to do | | - | - | | `503 channel_display_failed` | No channel is enabled. | | `400 no_channel_for_required_phone` at `/connect/authorize` | The phone number is required, and none of the channels available to the request can provide it — `acr_values` may have narrowed them. The requirement comes from `RequirePhone` or `Level: Required` on `phone_number` in `Veriqa:ScopesClaims`, from `RequirePhone` or `ClaimLevels` of the client entry, or from `essential: true` on `phone_number` in the request's `claims` parameter. If no registered channel can meet a configured requirement, a warning about it was already logged at startup. Enable a channel that provides the phone number, or lift the requirement. | | `400` · `browser_nonce_mismatch` — `Browser nonce does not match` at `/connect/authorize/callback` | A **different** transaction was confirmed: the authorize page was reloaded, and the one-time browser cookie belongs to the latest load. This is the built-in protection against confirming someone else's session. Retry the sign-in without reloading the page. | | `500` · `email_delivery_failed` at `/auth/email/start`, on **every other** attempt | A known limitation: on an SMTP server without authentication the sender reconnects over an already open connection, so every second attempt fails. A repeat request goes through. | | `429` at `/auth/email/start` | The Email channel's rate limit after several requests in a row. Pause between requests when debugging. | ## Symptoms on the application side | Symptom | Cause and what to do | | - | - | | A silent `Login failed! Please try again.` in TYPO3 while the Veriqa logs are **green** | The user has no group: `usersDefaultGroup` is unset. The reason is logged at INFO and invisible under TYPO3's default logger — see [TYPO3](/docs/integrations/typo3). | | `No email address provided by <provider>` in Drupal | The channel returned no email, and Drupal requires one on **every** sign-in. The fix is on the [Drupal](/docs/integrations/drupal) page. | | WordPress returns to `wp-login.php?login-error=invalid_request` | The same extra `scope` in the code exchange — see [WordPress](/docs/integrations/wordpress). | | In Bitrix every sign-in signs the user in as the administrator | A `CUser::GetList` filter written in D7 syntax is silently dropped and the first user is picked. The details are on the [Bitrix](/docs/integrations/bitrix) page. | | In Moodle, "Error in OpenID Connect. Please check logs for more information." | Moodle never reached Veriqa: its outbound-request protection fired — by address (`curlsecurityblockedhosts`) **or by port** (`curlsecurityallowedport`, only 443 and 80 by default). The real cause (`The URL is blocked.`) shows up only after the plugin's `debugmode` is switched on. The port restriction applies on a public address too — see [Veriqa in business systems](/docs/integrations/apps). | | In Moodle, "The given username contains invalid characters" | Moodle builds the username from the Veriqa `sub`, which looks like `telegram:1234` — a colon is not allowed in logins. Request the `profile` scope, so the name comes from `preferred_username`, or enable `extendedusernamechars`. | | In Odoo the provider button is there, but sign-in fails with a generic access-denied error | The OIDC module is missing the `python-jose` library: its import is wrapped in a silent branch that only writes to the debug log. The module installs and looks healthy regardless. | | In Odoo a bare error page opens instead of returning to the site | The stock `auth_oauth` module is configured, and it only speaks the implicit flow. See the `unsupported_response_type` row above. | ## Log entries at startup | Symptom | Cause and what to do | | - | - | | On the **first** start against an empty PostgreSQL database, an `Error` in the category `Microsoft.EntityFrameworkCore.Database.Command`: `Failed executing DbCommand … SELECT "MigrationId", "ProductVersion" FROM "__EFMigrationsHistory"`, and in the PostgreSQL log `relation "__EFMigrationsHistory" does not exist`. When the audit trail is kept in a database too — on the standalone server it shares the transaction database by default — a second such entry follows for its own history table, `__AuditTrailMigrationsHistory`. With the [collected claims store](/docs/guides/storage#collected-claims-store) turned on, one more follows for `__CollectedClaimsMigrationsHistory` — also on the first start after turning the store on against a database already in use | **Normal behavior, nothing to fix.** Before applying [migrations](/docs/guides/storage) EF Core reads its migration history table; on an empty database that table does not exist yet, and EF Core logs the refusal at `Error` level. It then creates the table and applies the migrations — the start continues and the health checks turn green. On the next starts against the same database these entries are gone. If one repeats on **every** start, or the start does not finish, that is a real failure: check the connection string and the database user's rights. | <Note> The plugin's own log on the application side is often more useful than Veriqa's: a failure that happens **after** a successful code exchange looks like a normal sign-in in the Veriqa logs. If Veriqa is all green and the user is still not signed in, the cause is almost certainly on the CMS side. </Note> # Veriqa in business systems Source: https://veriqa.app/docs/integrations/apps How to connect Veriqa to GitLab, Keycloak, Grafana, Nextcloud, Discourse, Odoo and Moodle — what works, where the barrier is, how it is cleared, and what remains untested. Beyond CMS engines, Veriqa connects to the systems an employee signs in to every day: DevOps, monitoring, file storage, a forum, an ERP, an LMS, and another identity provider. The OIDC client is **free** everywhere, but not always built in: GitLab, Keycloak, Grafana, Nextcloud and Discourse ship one, while Odoo and Moodle need a module installed from outside the distribution. ## What was verified | System | Mechanism | What works | | - | - | - | | GitLab CE 19.3.1 | omniauth `openid_connect` | sign-in, JIT provisioning, linking by `sub` | | Keycloak 26.7.2 | identity brokering | sign-in, user creation, linking by `sub` | | Grafana OSS 13.0.2 | Generic OAuth | sign-in, user creation | | Nextcloud 31.0.14 | `user_oidc` 8.11.0 app | sign-in, user creation, JWKS signature check | | Discourse 3.5.0 | `openid_connect`, built into core | signing in an **existing** user, linking by `sub`; account creation untested | | Odoo 18 Community | OCA `auth_oidc` 18.0.1.1.0 | sign-in, user creation, linking by `sub`, JWKS signature check | | Moodle 5.0.1 | `auth_oidc` 5.0.6 (Microsoft) | sign-in, user creation, linking by `sub` | Verified on the **Email** channel, with no changes on the Veriqa side. ## GitLab **The barrier.** Out of the box the sign-in fails: `omniauth_openid_connect` puts `scope` into the `grant_type=authorization_code` request, where [RFC 6749 §4.1.3](https://www.rfc-editor.org/rfc/rfc6749#section-4.1.3) does not define it. Veriqa answers `invalid_request` and GitLab reports: ``` Could not authenticate you from OpenIDConnect because "Invalid request :: the 'scope' parameter is not valid in this context." ``` **What to do.** Add one flag to the provider `args`: ```ruby theme={null} send_scope_to_token_endpoint: false ``` **The account.** Created on first sign-in (JIT) and linked by the Veriqa `sub`. GitLab treats the address from the `email` claim as **already confirmed** and sends no verification mail of its own. <Note> When no `email` claim arrives, GitLab does not refuse: it creates the user, grants a session and fills in a service address of its own, shaped like `temp-email-for-oauth-{login}@gitlab.localhost`, while prompting the user to complete the profile. </Note> ## Keycloak Here Veriqa is attached to **another identity provider** as an external OIDC provider: Keycloak accepts the sign-in from Veriqa and then acts as the provider for its own applications. **There is barely any configuration.** Keycloak imports the Veriqa discovery document and fills in every address itself, `jwks_uri` included, and turns on signature validation on its own. **The barrier is the last name, not the email.** The realm user profile requires `email`, `firstName` and `lastName` by default. Veriqa emits `family_name` only when the channel supplies a last name: a messenger takes it from the channel profile, where it is optional, and Email never supplies one. So the first sign-in stops at the "Update Account Information" form — always on the Email channel, and for some users on the others. **What to do.** Realm settings → User profile: clear the required flag on `firstName` and `lastName`. <Warning> Turning off the provider profile review (`update.profile.on.first.login = off`) does **not** solve this, although it looks like it does: the user is created, but Keycloak immediately raises the `VERIFY_PROFILE` required action carrying the same demand — the account exists, the session does not. Only the realm user profile clears the requirement. </Warning> **Two completion forms in a row.** When a sign-in lacks a claim that Veriqa collects itself, Veriqa asks the user for it on its own form before handing the sign-in back. A relying party with a profile completion form of its own — Keycloak, and Logto as well — then shows a second form right after it. For such an application turn the Veriqa form off with `ClaimCompletionFormEnabled: false` in its entry of `Veriqa:OpenIddict:Clients` (see [Clients](/docs/reference/configuration#clients)). With the form off, the missing claims are not issued and do not block the sign-in: the relying party asks for them on its own form. The phone number and the email are not affected — Veriqa obtains them by paths of their own. ## Grafana **The barrier.** With no `email` claim Grafana does not refuse outright — it goes looking for the address by a GitHub-compatible convention, requesting `{api_url}/emails`, which does not exist in OpenID Connect. It gets a 404 and stops the authentication: ``` level=error msg="Error getting email address" url=https://<issuer>/connect/userinfo/emails error="unsuccessful response status code 404" ``` The message misleads: the cause is a missing claim, not a network failure. **What to do.** Make sure `email` arrives. The Email channel provides it; on a channel that carries no address (Telegram) Veriqa collects it inside the sign-in when `email` is required, or your own code supplies one — see [Claim enrichment](/docs/concepts/claim-enrichment). **The account.** Created automatically, with `email`, `name` and `login` all set to the address. <Note> Grafana sends no `nonce` and does not verify the `id_token` signature against JWKS — it reads the claims straight from the token body. It also tries to parse the Veriqa access token as a JWT and logs `token is not in JWT format`: the access token is not a JWT — today it is an opaque reference token, and at the time of the run it was a JWE — and this does not affect sign-in. </Note> ## Nextcloud Its barriers are in the transport rather than in the claims, and both are cleared by Nextcloud settings. **HTTPS only.** Over plain HTTP `user_oidc` returns a 404 page reading "You must access Nextcloud with HTTPS to use OpenID Connect". The check passes over HTTPS, in debug mode, **or** with the `allow_insecure_http` app setting — the last one is meant for an internal network. **Private-address protection.** Until `allow_local_remote_servers=true` is set, Nextcloud will not reach an issuer on a private network. It is hard to diagnose: the return leg answers 403 and the only trace is a line in `nextcloud.log`: ``` Host <issuer-host> was not connected to because it violates local access rules ``` In production neither setting is needed: the issuer is public and served over HTTPS. **Linking — decide before the first sign-in.** Nextcloud builds the user id in one of three ways: | Provider setting | What lands in the uid | | - | - | | `uniqueUid = true` (default) | `sha256` of the provider id and `sub` — an opaque hash | | `uniqueUid = false` | the Veriqa `sub` verbatim | | `providerBasedId = true` | `{provider}-{sub}` | If you intend to match Nextcloud accounts to your own by `sub`, clear `uniqueUid` **before** the first sign-in: changing the setting later does not rename users who already exist. <Note> Nextcloud verifies the `id_token` signature against JWKS and does **not** call `/connect/userinfo` at all — every claim comes from the `id_token`. Claims that Veriqa serves only from userinfo will not reach it; enable `enrichLoginIdTokenWithUserinfo` if you need them. </Note> ## Discourse The `openid_connect` plugin is built into core — there is nothing to install. There are two barriers, and both are cleared by forum settings. **By default Discourse does not request an email.** The `openid_connect_authorize_scope` setting contains only `openid`. For a forum that is a dead end: without an address no account is created and the sign-in stops at the registration form. Set it to `openid email profile`. **Private-address protection.** While the issuer sits on a private network Discourse will not reach it: `allowed_internal_hosts` must list its host. The browser meanwhile receives a generic `openid_connect_discovery_error` — the cause is visible only with `openid_connect_verbose_logging` turned on. In production the barrier does not arise. <Warning> A failed discovery read is **cached**. After you fix the setting Discourse keeps answering with the same error for a while, which looks as though the fix did not work. Clear the cache. </Warning> **What is verified and what is not.** Signing in an existing user works, the association is written against the Veriqa `sub`, and Discourse verifies the `id_token` signature against JWKS. Creating a new account is **not verified**: Discourse performs it in a separate request from the browser, guarded by anti-bot checks. ## Odoo **The module in the box will not do.** The stock `auth_oauth` only speaks the implicit flow: it asks for `response_type=token`, sends no `nonce`, and verifies no signature. Veriqa answers `unsupported_response_type`: the implicit flow was removed from [OAuth 2.1](https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1) as unsafe. From the outside it looks like an error page instead of a return to the site. **What to install.** The [`auth_oidc`](https://github.com/OCA/server-auth) module from the OCA repository (the branch matching your Odoo version). In the provider settings choose `flow = OpenID Connect (authorization code flow)` and fill in the fields that appear: Token URL, JWKS URL and Client Secret. The JWKS URL is what lets Odoo verify the Veriqa signature — and it does. <Warning> The module requires the `python-jose` library, which the official Odoo image does not carry. Its import failure is handled silently, with only a line in the debug log, so without the library the module installs, the sign-in button appears, and the sign-in itself fails with a generic access-denied error. Install `python-jose` together with the module. </Warning> **The account.** Created automatically — free sign-up is on by default in a fresh Odoo database, so nothing extra needs enabling. The link is written against the Veriqa `sub` in the `oauth_uid` field and the address becomes the login. When no address arrives, Odoo makes up a synthetic login of its own and still creates the user. ## Moodle Moodle has no OIDC client of its own; the one in use is the [`auth_oidc`](https://moodle.org/plugins/auth_oidc) plugin Microsoft ships for Entra ID. Sign-in works, the user is created and the link is written against the Veriqa `sub` — but three barriers have to be cleared, and two of them you will meet in production as well. **The plugin sends someone else's parameter.** In every mode except "Microsoft Identity Platform" it adds a `resource` parameter to the request, and when the setting is left empty it substitutes a hard-coded Microsoft Graph address. Veriqa answers `invalid_target`: Veriqa registers no [RFC 8707](https://www.rfc-editor.org/rfc/rfc8707) resources, so **no** value passes. There are two ways through, and the one on the Veriqa side needs no change in Moodle: enable the per-client quirk [`drop-resource-parameter`](/docs/guides/client-compatibility) for this `client_id` and the parameter is stripped before validation. Nothing consumes it here, so the issued token is the one a client that never sent it would get — no resource is registered and no audience granted. <Warning> On the Moodle side there is exactly one way around this — set the provider type to **"Microsoft Identity Platform"**, which drops the parameter. It works, but it deliberately mislabels the provider in your settings: the plugin offers no generic "other provider" mode without `resource`. The recommended route is the quirk above. </Warning> **Protection against private addresses — and against ports.** Moodle blocks outbound requests by address (`curlsecurityblockedhosts`) and also by port: `curlsecurityallowedport` allows only 443 and 80 by default. Unlike the other systems, this barrier **does not go away by itself in production** — if your issuer listens on a non-standard port, that port has to be allowed explicitly. The diagnostics are thin: the browser shows "Error in OpenID Connect. Please check logs", and the real cause (`The URL is blocked.`) appears only after the plugin's debug mode is switched on. **A colon in the identifier.** If Moodle has nothing but the Veriqa `sub` to build a username from, sign-in stops at "The given username contains invalid characters": the `sub` looks like `telegram:1234` or `email:address`, and Moodle does not accept a colon in logins. Two ways out: request the `profile` scope, so the name comes from `preferred_username`, or enable `extendedusernamechars`. **After creation.** Moodle sends the new user to a "complete your profile" page: first and last name are mandatory there, and a channel does not always supply them. ## The email requirement: four different behaviors Telegram carries no address, and systems treat its absence differently. The difference is practical: it decides whether you need a substituted address at all. | Behavior | Systems | Substituted address needed | | - | - | - | | Refuses before creating the account | Grafana | yes | | Account is created, but there is no session until an address is entered | Keycloak | no — cleared by a setting | | Account and session exist, the system fills in an address itself | GitLab, Odoo | no | | No address required, the field stays empty | Moodle | no | For the same picture across CMS engines, see the [CMS integrations overview](/docs/integrations/overview). ## Local testing: every system has its own switch When you stand the pair up on your own machine the issuer ends up on a private network, and almost every system guards against calls there with a flag of its own: | System | Setting | | - | - | | Nextcloud | `allow_local_remote_servers = true` | | WordPress | `allow_internal_idp = 1` | | Drupal | `allow_private_issuer` | | Discourse | `allowed_internal_hosts` | | Moodle | `curlsecurityblockedhosts` — plus `curlsecurityallowedport` separately | The first four barriers disappear in production along with the public issuer. **Moodle is the exception:** it also restricts the port, so it remains an obstacle on a public address too when the issuer listens on something other than 443 or 80. More on local test setups in [Local testing](/docs/integrations/local-testing). ## Not verified * **Channels other than Email.** On Telegram the expected difference is the address requirement (see the table above). * **Merging with an account already present locally** — outside Discourse, every system above was verified with a newly created user. * **Roles, groups and permissions.** The user gets the default role; mapping groups from claims was not tested in any of the systems. * **Repeat sign-in and profile refresh** from the provider on subsequent sign-ins. # 1C-Bitrix Source: https://veriqa.app/docs/integrations/bitrix Signing in to Bitrix through Veriqa on the stock socialservices framework: why the built-in OpenID does not fit, the two mandatory settings, and the CUser::GetList trap that can sign anyone in as the administrator. Bitrix has no OIDC client of its own, but it does have a stock social-authentication framework that Veriqa plugs into as an ordinary provider. The button appears in the standard sign-in form, and the platform itself handles finding, creating and linking accounts. <Note> Verified on "Site Management: Business" with the Telegram and Email channels: sign-in, automatic user creation, linking in `b_socialservices_user`, and the button in the stock form. </Note> ## What will not work The built-in `COpenIDClient` is **OpenID 2.0**, not OpenID Connect: it has `openid.ns` and `check_authentication`, but no `id_token`, no JWKS and no PKCE. It is fundamentally incompatible with Veriqa. By default the sign-in form shows exactly the services of that era — Livejournal, Rambler, Blogger, Mail.Ru OpenID. The marketplace module `anighr.openidconnect` has not been tested: the marketplace requires a license key. ## The path that works — a `socialservices` provider The whole scheme fits into stock extension points: 1. a handler for the `OnAuthServicesBuildList` event returns the provider descriptor (`ID` / `CLASS` / `NAME` / `ICON`) — after that the button shows up in the sign-in form **by itself**, with no template changes; 2. the provider class returns the link to `/connect/authorize` and handles the return; 3. `CSocServAuth::AuthorizeUser()` takes care of finding, creating and linking — the link lands in `b_socialservices_user`. A ready-made OIDC example already ships with the platform: `apple.php` in the `socialservices` module. <Warning> Keep the callback in `/local/`, not in `/bitrix/tools/oauth/` — the `/bitrix/` directory is overwritten by updates. The public `CSocServAuthManager::Authorize($id)` is enough for this. </Warning> ## Two settings, without which the sign-in never completes ``` main.new_user_registration = Y socialservices.allow_registration = Y ``` Without them `AuthorizeUser` returns `SOCSERV_REGISTRATION_DENY`, and from the outside that **does not look like a configuration problem** — it looks like a broken sign-in. On a production site mind the side effect: the first setting also enables Bitrix's own registration. ## The `CUser::GetList` trap — required reading `CUser::GetList` **does not understand the D7 `'=FIELD'` syntax**: it silently drops filter keys it does not recognize and behaves as if there were no filter at all. `Fetch()` then returns the first row — the user with id 1, the administrator. Code that means "found an existing user and signed them in" turns into **signing in as the administrator on every successful OIDC sign-in**. | Call | Result for an identifier that does not exist | | - | - | | `CUser::GetList(['=XML_ID' => …])` | **every user** | | `CUser::GetList(['THIS_FIELD_DOES_NOT_EXIST' => …])` | every user — identical | | `CUser::GetList(['XML_ID' => …])` — legacy keys | 0, correct | | `UserTable::getList(['=XML_ID' => …])` — D7 | 0, correct | There is no hole in Bitrix here: the platform writes those keys correctly. What breaks is **mixing dialects** in the integration code. The rule is simple: legacy keys with the legacy API, or D7 syntax with `UserTable::getList()` — never mixed. And look up "your" users through the links in `b_socialservices_user`, not through `b_user` fields. ## Three more framework pitfalls * **`getUrl()` is called twice** per form render (`GetFormHtml` and `GetOnClickJs`) — memoize the `state` and PKCE generation per request, or the second call overwrites the first. * **The core session is already open** inside the provider, so you do not need one of your own. `session_write_close()` cuts off the Bitrix session in the middle of rendering the page. * **`session_name()` changes for the whole process.** Open your own session without restoring the name before `prolog_before.php`, and the core opens a session under the wrong cookie: the sign-in "disappears" on the next request. ## Email Bitrix requires an email **only for a new** user, and the requirement can be turned off with `main.new_user_email_required`. That is gentler than Drupal and Joomla, where the address is needed on every sign-in. When the address does not arrive as a claim (Telegram does not provide one), substitute a synthetic one using the same convention as on the other engines: `{channel}-{id}@{channel}.veriqa.invalid`. The `.invalid` domain is reserved by [RFC 2606](https://www.rfc-editor.org/rfc/rfc2606) — mail to such an address goes nowhere, and the account gets neither notifications nor password recovery. A deliberate trade-off, not a free workaround. On the Email channel the address arrives as a claim and no synthesis is needed. <Note> The ownership marker is set by the platform: an account created through the framework gets `b_user.EXTERNAL_AUTH_ID = socservices`, put there by `CSocServAuth::AuthorizeUser` itself. Your own provider identifier never reaches that field, so searching by it is pointless. </Note> ## A pleasant side effect The stock `checkOldUser` recognizes "old scheme" accounts (`EXTERNAL_AUTH_ID` plus `XML_ID` directly in `b_user`) and moves them into the link table without creating duplicates — migrating from an older integration happens on its own. # Confirmed form submission Source: https://veriqa.app/docs/integrations/confirmed-form A form on your own site whose submission the user confirms in a messenger: no session is created, no CMS plugin does this, and a server-side handler is mandatory. The second scenario besides sign-in: the user fills in a form — a request, a question, a call-back form — and confirms the submission in a messenger. Next to the submission you get a record saying "this was confirmed by this subject in this channel", and **no session is created and no account is provisioned**. <Note> The handler fits in about 250 lines of PHP, **with no Composer and no external dependencies**; verified on the Telegram channel. </Note> ## Why this is not a sign-in setting <Warning> **No OIDC plugin for any CMS does this.** Plugins cover signing in to an account, while Contact Form 7, WPForms and Bitrix web forms are not tied to an account at all. "A member area" and "a confirmed submission" are two different solutions, not two settings of one. </Warning> So there is no plugin and no CMS recipe here: what you need is a small server-side handler placed next to the form. ## Why the handler must be server-side A browser-only script cannot do it, and it runs into three walls at once: * only the Authorization Code Flow (plus refresh) is supported — there is no implicit and no hybrid; * no endpoint has CORS configured; * the access token is an opaque identifier, and introspecting it takes a confidential client with a secret, which a browser script cannot keep. The practical consequence for SaaS site builders (Tilda, Wix, Shopify): the scenario needs hosting for that handler — the builder page alone is not enough. ## The flow 1. the user clicks the button under the form; 2. a redirect to `/connect/authorize` with PKCE S256, `state` and `nonce` — plus `acr_values` if a specific channel is wanted; 3. the return to `redirect_uri`, an ordinary page of your site; 4. **the server** exchanges the code for tokens and validates `iss`, `aud`, `exp` and `nonce`; 5. the submission gets a record: "confirmed: `sub` + `channel_type`"; 6. no authentication cookie is set — nobody signs in. ## About signature validation Validating the `id_token` signature with a JWT library is **not required** here: OIDC Core §3.1.3.7, rule 6 lets a client that obtained the token **directly from the token endpoint** rely on TLS verification instead of checking the signature. That is why the handler needs no JWT library and no Composer. <Warning> The relaxation has a price, and it is not negotiable: **TLS certificate verification must never be switched off** (`CURLOPT_SSL_VERIFYPEER` in PHP stays on). Without it, the very thing the permission rests on disappears. </Warning> ## Your own buttons on your own page The two buttons under the form — "via Telegram" and "any channel" — are a parameter of the authorize request: `acr_values=channel:telegram` narrows the choice to one channel, and without the parameter the user picks. <Note> There is no widget or JS SDK for this, and none is needed: "your own button" is a link with the right parameters, not an embeddable component. </Note> # Drupal Source: https://veriqa.app/docs/integrations/drupal Signing in to Drupal 10 through Veriqa: the modules, the three verification switches, the mandatory email, and what to do when the channel does not provide one. Drupal connects to Veriqa through stock contrib modules. Only the site side changes: the Veriqa server and its distribution stay untouched. <Note> Verified: Drupal 10 + `openid_connect` 3.0.0-alpha8 + `openid_client_advanced` 1.0.0-rc8; Telegram channel. Working: sign-in, user creation, **`id_token` signature checked against JWKS, nonce, and PKCE S256**. </Note> ## 1. Modules ```bash theme={null} composer require drush/drush "drupal/openid_connect:^3.0@alpha" "drupal/openid_client_advanced:^1.0@rc" drush en openid_connect openid_client_advanced ``` `openid_connect` 3.0.0 is an **alpha**, which is worth factoring into your planning. `openid_client_advanced` pulls in `firebase/php-jwt`. ## 2. The client and the three switches Configuration → People → OpenID Connect, the **advanced** plugin. Fill in `issuer_url`, the three endpoints (`/connect/authorize`, `/connect/token`, `/connect/userinfo`) and `scopes`. <Warning> PKCE is disabled in `openid_connect` itself at the code level (`usesPkce()` returns FALSE). PKCE S256, signature validation and nonce live in `openid_client_advanced`, and **all three are off by default**. "Drupal does PKCE" is only true once you have turned them on. </Warning> Turn on all three: `use_pkce`, `use_nonce`, and `validate_signature` with `allowed_algorithms: [RS256]`. The keys go in as **JWKS JSON** — the field expects the contents of the `/.well-known/jwks` document, not a link to it. Three more settings whose absence produces surprising behavior: * `openid_connect.settings.user_login_display = above` — otherwise the sign-in buttons are simply not visible on the form; * `override_registration_settings` and `user.settings.register` — they decide whether accounts may be created through the provider. The `redirect_uri` is `{site address}/openid-connect/{client entity id}`. ## 3. Drupal requires an email — on every sign-in The most important part of this recipe. `openid_connect` rejects the sign-in when the provider returns no address: the log shows `No email address provided by <provider>`, and there is no setting to bypass it. <Warning> The check sits in context building, before the account is looked up — not in registration. So an **already registered** user without an email will not get in either: the email is required on every sign-in, not just the first one. </Warning> Telegram does not hand out addresses and never will. The way out is a hook the module documents itself (`openid_connect.api.php`) and invokes **before** that check, so the fix lives in your own module while the contrib module and Veriqa stay untouched: ```php mymodule.module theme={null} /** * Implements hook_openid_connect_userinfo_alter(). */ function mymodule_openid_connect_userinfo_alter(array &$userinfo, array $context): void { if (!empty($userinfo['email'])) { return; } // With the default resolver `sub` already carries both parts as "{channel_type}:{channel_user_id}". $parts = explode(':', (string) ($userinfo['sub'] ?? ''), 2); if (count($parts) !== 2) { return; } [$channelType, $channelUserId] = $parts; $local = preg_replace('/[^a-z0-9]+/i', '-', $channelType . '-' . $channelUserId); $userinfo['email'] = strtolower($local) . '@' . strtolower($channelType) . '.veriqa.invalid'; } ``` The address comes out as `telegram-123456789@telegram.veriqa.invalid`. The `.invalid` domain is reserved by [RFC 2606](https://www.rfc-editor.org/rfc/rfc2606) and is guaranteed never to be delivered to — a deliberate trade-off rather than a free workaround: **an account with such an address gets no password resets and no notifications.** For a production site that is a decision you still have to make, not a finished answer: ask for the email in a second step, use progressive enrichment, or use the Email channel, where the address arrives in the claims. ### `connect_existing_users` — the setting you cannot ignore For a new user Drupal compares the address against existing accounts. With `connect_existing_users` enabled it will **link the account it finds** to the identity from Veriqa. With synthetic addresses that is safe: they are deterministic and unique per channel and id. But as soon as Veriqa starts returning a **real** address (the Email channel, or a resolver of your own), signing in through Veriqa takes over the local account with that address — the classic account takeover through an unverified email. Enabling this setting makes sense only when the IdP's address verification is something you can rely on. ## What you get The account is created with the name from `preferred_username` (`alice`), and `authmap` gains a row `provider=openid_connect.veriqa, authname=telegram:123456789` — **Drupal links the identity by the Veriqa `sub`**. The practical consequence: for Drupal, `sub` is the account key. If the way it is composed on the Veriqa side ever changes, previously created users stop being recognized. ## Limitations * Verified with the **Telegram** channel; Drupal with Email, WhatsApp or the other channels has not been tested. * The hook above is an example, not a production-ready module: the decision about addresses belongs to the site. # Joomla Source: https://veriqa.app/docs/integrations/joomla Signing in to Joomla 5 through Veriqa with the miniOrange OAuth Client: three installation pitfalls, the In Header checkbox, and the main limitation — commercial rather than technical. Users sign in to Joomla through Veriqa with the miniOrange OAuth Client plugin. The technical side is clean — no code changes at all. The limitation here is different: **the free version does not create users**. <Note> Verified: Joomla 5 + miniOrange OAuth Client 3.2.0 (free); Telegram channel. Working: sign-in **to an existing account**. The free build has no user auto-creation. </Note> <Warning> **The main limitation is commercial.** To an unknown user the free version shows "User Auto-Creation Not Available in Current Plugin Version" with an upgrade link. The build contains no account-creation code at all — only a lookup of an existing account by `email = X OR username = Y`. So what you get for free is **a different scenario** than on WordPress and Drupal: there the messenger covers registration from scratch, here it only signs people in to accounts an administrator created. </Warning> ## Installation and setup Install miniOrange OAuth Client 3.2.0 (GPLv2, J3–J6). The zip downloads from a direct link and no miniOrange account is needed. The open alternative `damyvv/openidconnect-joomla` is dead — last commit in 2023, it never saw Joomla 5. 1. Install the package. 2. **Enable the `PLG_SYSTEM_MINIORANGEOAUTH` plugin** — it arrives disabled. 3. Configuration → the **Custom OpenID Connect App** provider → `client_id`, `client_secret`, the three endpoints, scopes. 4. **Clear the "In Header" checkbox** in the "Set Client Credentials" block. On Joomla 4+ the `redirect_uri` is a web-service route rather than a site page: `{site}/api/index.php/v1/miniorangeoauth`. The sign-in link is the same path with `?morequest=oauthredirect&app_name=openidconnect`. ## Three installation pitfalls 1. **The installer is misleading.** You get `Can't find XML setup file` (a warning) right next to `Installation successful`. The installation is fine — the warning is about an intermediate archive inside the package. 2. **The system plugin is disabled after installation.** Without it the sign-in does not work, and the interface does not make this obvious. 3. **`ID2087` at the code exchange.** By default the plugin sends the credentials **both** in an `Authorization: Basic` header **and** in the request body, while RFC 6749 §2.3.1 allows only one method. Clearing "In Header" fixes it. The plugin's own message ("check your client id and secret") points the wrong way — they are not the problem. ## Email: the requirement exists, and it is stricter than Drupal's The plugin resolves the email **before** looking up the account, that is, on every sign-in, and aborts in two cases: the `email_attr` mapping is unset ("Login not Allowed. Attribute Mapping is empty"), or it is set but the attribute arrived empty (`[MOOAUTH-008] : Email attribute is not received.`). The value's format is never checked — the requirement is about a **non-empty value**, not about an email address. <Warning> **`email_attr` corrupts the account.** On every sign-in the plugin overwrites the account's email with the mapped value, without validating the format. If you map it to `sub`, `joom_users` ends up holding `telegram:123456789` instead of `alice@veriqa.invalid`, after which password resets and notifications stop working. There is **no correct value** for Telegram: it hands out no address. The supported way around this is the paid version with attribute mapping. </Warning> ## What the free version covers Configuration and Logger are available. User auto-creation, attribute and role mapping, and advanced login are not. Verified with the **Telegram** channel; Joomla with Email, WhatsApp or the other channels has not been tested. # Testing the integration locally Source: https://veriqa.app/docs/integrations/local-testing How to stand up a CMS plus Veriqa on your own machine, and which difficulties exist only locally — production is free of these particular ones. Running the integration on a laptop before rollout is sensible. But nearly every difficulty of a local setup is created by the setup itself, not by Veriqa. <Note> **Everything on this page is about local testing.** In production, where Veriqa has a real domain and a valid certificate, none of it applies: the production setup is **simpler** than the local one. </Note> ## The certificate A self-signed certificate has to be mounted inside **every** container, followed by `update-ca-certificates` — otherwise PHP's cURL breaks the code-for-tokens exchange. In production this step does not exist at all. <Warning> **The system trust store is not enough for everyone.** `update-ca-certificates` puts the CA where PHP and Ruby look, but not every runtime reads that store. Python clients built on `requests` — Odoo's OIDC module, for one — use their own `certifi` bundle and keep breaking the code exchange even with the CA installed; they need `REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt`. The rule is general: the trust store belongs to the runtime, so check the one the client is written in, not the one the site runs on. </Warning> A separate trap: **antivirus software that intercepts TLS** replaces your self-signed certificate with its own and shows an "untrusted certificate" interstitial. The "continue" link on such a page drops the query string — that is, the whole OIDC request — so it cannot be passed either by hand or programmatically. Server-side calls are unaffected: PHP inside the container uses the mounted CA and never sees the problem. A workaround that does not touch the machine's security settings: expose **two HTTPS endpoints** — one with your own certificate for the containers, and one with a dev certificate already trusted by the system for your own browser. ## The issuer address Three rules: * **the issuer must be https.** On `http://localhost:5200` the core refuses even in Development. Note that a `redirect_uri` over `http` is still accepted — the restriction is about the issuer address specifically; * **the address must resolve identically** from inside the container and from the host, or the `iss` in discovery drifts away from what the browser sees; * **a private address is blocked by clients.** `host.docker.internal` is a private network, and almost every system guards against calls there with a flag of its own: WordPress cuts such requests off via `wp_safe_remote_post()` (fixed by the `allow_internal_idp` setting), Drupal via `allow_private_issuer`, Nextcloud via `allow_local_remote_servers`, Discourse via `allowed_internal_hosts`, Moodle via `curlsecurityblockedhosts`. In production the address is public and none of these flags are needed. <Warning> **One exception that does not go away in production.** Moodle restricts outbound requests not only by address but also **by port**: `curlsecurityallowedport` allows just 443 and 80 by default. If your issuer listens on a non-standard port, that port has to be allowed explicitly on the production address too. </Warning> ## Hop mode on a local setup [Hop mode](/docs/reference/configuration#veriqahopmode) is worth trying locally — it is the setting we recommend for production. It adds one condition to the issuer rules above: the QR code carries a link to the issuer address, and the **phone** opens it. A phone cannot reach `localhost` on your machine, and it does not trust your self-signed certificate. Put Veriqa behind an **HTTPS tunnel** — any service that gives a local port a public `https` address with a valid certificate — and set that address as the issuer (`Veriqa:OpenIddict:Server:Issuer`). The rule about one address everywhere applies here too: the browser, the containers and the phone must all see the tunnel address, not `localhost`. Then switch hop mode on with `Veriqa:HopMode:Enabled = true`. In production none of this is needed: the issuer is already a public `https` address. ## Environment odds and ends | What | Symptom | | - | - | | Container DNS | package mirrors may fail to resolve from inside the container; fixed by setting DNS explicitly in compose | | Application directory permissions | in a named volume the directory belongs to root and Composer running as `www-data` cannot write to it | | `AllowOverride None` in the `php:apache` image | TYPO3 serves its frontend through `.htaccess`: the home page opens while any human-readable URL returns a 404 from **Apache itself** — which is how you recognize it | | Re-running `typo3 setup --create-site` | only works on a clean database; on a provisioned one it fails and no site configuration is created | ## Mail For the Email channel it is convenient to run a local mail catcher instead of real SMTP: nothing leaves the machine, and the link from the message is visible in its web UI. <Warning> On SMTP **without authentication** mail delivery works every other time: the connection is reused incorrectly and every second attempt returns an error. A repeat request goes through. This is a known limitation, not a consequence of your configuration — see [Troubleshooting](/docs/guides/troubleshooting). </Warning> One more: the Email channel has a rate limit, so debugging with several requests in a row will produce a `429`. Leave a pause between attempts. ## What a local setup does not verify * **mail deliverability** — SPF, DKIM, DMARC and domain reputation mean nothing to a local catcher; * **vendor platform behavior** — Shopify and Wix reach Veriqa from the outside and need its public HTTPS address, see [SaaS builders](/docs/integrations/saas-builders); * **performance under load** — local response numbers say nothing about production. # Veriqa in a CMS Source: https://veriqa.app/docs/integrations/overview What exactly was verified on WordPress, Drupal, TYPO3, Joomla and Bitrix, how the engines differ on requiring an email, and the limitations. Veriqa plugs into a CMS as an ordinary OpenID Connect provider, and CMS engines connect **with no changes on the Veriqa side**. Everything needed lives on the integrator's side — from a six-line file on WordPress to a `socialservices` provider on Bitrix. ## What was verified | Engine | What works | | - | - | | [WordPress](/docs/integrations/wordpress) 6 + `openid-connect-generic` 3.11.3 | sign-in, user creation, signature checked against JWKS | | [Drupal](/docs/integrations/drupal) 10 + `openid_connect` 3.0.0-alpha8 | sign-in, user creation, **JWKS signature + nonce + PKCE S256** | | [TYPO3](/docs/integrations/typo3) 13.4.34 + `causal/oidc` 5.0.0 | sign-in, `fe_user` creation and a session, linking by `sub` | | [Joomla](/docs/integrations/joomla) 5 + miniOrange OAuth Client 3.2.0 | sign-in **to an existing** account; auto-creation is paid | | [Bitrix](/docs/integrations/bitrix) "Business" + a `socialservices` provider | sign-in, auto-creation, linking, button in the stock form | Two independent clients — Drupal and WordPress — verified the Veriqa `id_token` signature against JWKS and accepted it. ## The client must be confidential A requirement common to all five engines: register the client **with a secret**. Veriqa lets such a client work without PKCE, and that is what makes off-the-shelf plugins workable. A public client without PKCE is refused, and `plain` is accepted from nobody. ## Email — where one engine's recipe stops transferring to another Telegram does not hand out an email and never will, and the engines disagree on what to do about that. "A CMS requires an email at registration" is wrong for three rows of this table: | Engine | Requires an email | For whom | Where it is decided | | - | - | - | - | | Drupal | yes, hard | both — an existing user cannot sign in either | hardcoded, no switch | | Joomla | yes, hard | on every sign-in | hardcoded in the plugin, format unchecked | | Bitrix | yes by default | only for a new user | switched off by `main.new_user_email_required` | | WordPress | no | — | the user is created with an empty address | | TYPO3 | no | — | email is outside the default mapping | Where the address is mandatory and the channel provides none, a synthetic address of the form `{channel}-{id}@{channel}.veriqa.invalid` is used. The `.invalid` domain is reserved by [RFC 2606](https://www.rfc-editor.org/rfc/rfc2606) and is never delivered to — **an account holding one gets no notifications and no password recovery**. That is a decision for the integrator (ask for the address in a second step, use the Email channel), not a finished answer. ## Limitations * **channels other than Telegram**: WordPress, Drupal, Joomla and TYPO3 were verified on Telegram only. "CMS + Email" was verified end to end **on Bitrix only**; WhatsApp and the other channels were not tested with any CMS; * **a public deployment** with a real domain and a valid certificate was not tested. It removes the local obstacles (a self-signed CA, a private issuer) — see [Local testing](/docs/integrations/local-testing). Systems that are not CMS engines — GitLab, Keycloak, Grafana, Nextcloud — have a page of their own: [Veriqa in business systems](/docs/integrations/apps). SaaS site builders (Shopify, Wix) connect through the platform's own SSO settings: see [SaaS builders](/docs/integrations/saas-builders). # Shopify, Wix and other SaaS builders Source: https://veriqa.app/docs/integrations/saas-builders Can Veriqa be connected to a SaaS platform as its external sign-in provider, and what it takes: the email scope, a public address and the platform's SSO settings. Yes, it can. SaaS site builders accept an external OpenID Connect provider in their own **SSO settings**, so no plugin is needed: Veriqa is connected as an ordinary OIDC provider. ## What it takes 1. **The `email` scope.** Shopify and Wix use the email address as the key of the customer or site member account, so the platform needs the `email` claim. Allow `email` in the client's `AllowedScopes`. The address comes from the Email channel — the messenger channels do not provide an email address, so for such a client the user signs in through the Email channel. What each channel provides is in the [channel catalog](/docs/guides/supported-channels). 2. **A public HTTPS address for Veriqa with a valid certificate.** The platform calls discovery, token and userinfo from its own servers, so a local test setup will not do. 3. **The SSO settings on the platform side:** a `Client ID`, a `Client Secret` and Veriqa's discovery address. The platform generates the callback URL itself — add it to the client's allowed redirect URIs. ## Shopify The feature is called "connect your own identity provider to customer accounts" and runs over OIDC. Shopify's requirements for an external provider and how Veriqa meets them (checked with a full authorization code flow on the Email channel): | Shopify requirement | Veriqa | | - | - | | Discovery endpoint | present | | JWKS endpoint | present | | Authorization code flow only | `response_types_supported: ["code"]` | | PKCE `S256` for public clients | supported | | RS/ES/PS/EdDSA signatures, HS256 forbidden | `RS256` | | `sub`, `iss`, `aud`, `nonce` in the `id_token` | all present, `nonce` matches | | `email` in the `id_token` | arrives with the `email` scope on the Email channel | | `email_verified` is `true` | JSON boolean `true` | | Refresh tokens (sessions up to 90 days) | the `refresh_token` grant with the `offline_access` scope | | Discovery / token / userinfo answer in under a second | 0.26–0.29 s on a local installation | | RP-Initiated Logout (`end_session_endpoint`) | **not supported** | <Warning> Veriqa has no RP-Initiated Logout: a customer signing out of the store does not end the session on the Veriqa side. `revocation_endpoint` (RFC 7009) revokes tokens — a different operation, and it does not satisfy this Shopify requirement. The full list of what is and is not supported is in [OIDC and OAuth 2.0 support](/docs/reference/oidc-capabilities). </Warning> <Note> Shopify also has legacy mechanisms (Multipass, the Storefront API token flow), but its own documentation recommends moving off them to an external IdP — do not build on them. </Note> ## Wix Wix accepts an external provider over OIDC as well, and also without a plugin: its SSO settings take a `Client ID`, a `Client Secret` and a **Config URL** — Veriqa's discovery address — and the platform pulls the rest itself. Field mapping in Wix is configurable. If site members are identified by their email address, request the `email` scope just as for Shopify. ## If you need a confirmed submission rather than a sign-in If the goal is not "a member area on the platform" but "a confirmed submission", no SSO settings are needed at all: a small server-side handler next to the form is enough, see [Confirmed form submission](/docs/integrations/confirmed-form). # TYPO3 Source: https://veriqa.app/docs/integrations/typo3 Signing in to TYPO3 13 through Veriqa with causal/oidc: the settings.php keys, the mandatory default group, and what the extension does and does not verify. TYPO3 connects through the `causal/oidc` extension — the most recently updated OIDC client among the tested engines. Nothing changes on the Veriqa side. <Note> Verified: TYPO3 13.4.34 + `causal/oidc` 5.0.0; Telegram channel. Working: sign-in, `fe_user` creation and a session, identity linked by the Veriqa `sub`. </Note> ## 1. Installation ```bash theme={null} composer create-project "typo3/cms-base-distribution:^13" composer require causal/oidc:^5 vendor/bin/typo3 extension:setup ``` The extension needs TYPO3 12.4.33+/13.4.14+ and PHP 8.2+, and pulls in `league/oauth2-client` and `firebase/php-jwt`. ## 2. Configuration The settings live in `config/system/settings.php` under `EXTENSIONS.oidc`, not in the database: | Setting | Value | Why | | - | - | - | | `enableFrontendAuthentication` | `1` | otherwise the authentication service is never wired up | | `enableCodeVerifier` | `1` | enables PKCE; `S256` only | | `oidcClientKey` / `oidcClientSecret` | from the Veriqa client | the client must be confidential | | `oidcClientScopes` | `openid profile email` | | | `oidcEndpointAuthorize` / `Token` / `UserInfo` | the three addresses from discovery | **`UserInfo` must not be left empty** — see below | | `oidcRedirectUri` | any page of the site | | | `usersStoragePid` | uid of the storage folder | otherwise there is nowhere to put `fe_users` | | `usersDefaultGroup` | uid of an `fe_groups` record | **mandatory**, see the next section | The `redirect_uri` is free-form in TYPO3 — unlike Drupal and Joomla, where the module dictates it. A middleware intercepts any frontend GET carrying `code`/`state`/`error` in the query, so you can register, for example, the site root. <Warning> Give the sign-in its own page. The `oidc_login` plugin redirects to the IdP **as the page renders** — putting it on the front page sends every visitor to sign in. </Warning> ## 3. The default group — without it the sign-in does not work This is TYPO3's main trap, and it is dangerous twice over. The extension **does not create a new user who ends up in no group**. Groups come either from a `Roles` claim — **Veriqa does not issue one** — or from `usersDefaultGroup`. So you have to create an `fe_groups` record by hand and point the setting at its uid. The other half of the trap is diagnostics. The refusal is logged at **INFO**, while TYPO3's default logger has its threshold at **ERROR**. From the outside it looks like this: * the page shows a bare **"Login failed! Please try again."**; * the TYPO3 log says nothing about the reason; * the Veriqa logs are **all green**: token 200, userinfo 200. The failure happens **after** a successful exchange, so the integrator looks for the problem at the IdP while it sits on their own side. Turn on a diagnostic logger **before** the first sign-in, not after. ## 4. What the extension verifies — and what it does not <Warning> The extension **does not verify the `id_token` signature** — it has no JWKS setting at all: the identity comes from the `/connect/userinfo` response, with trust resting on TLS. That is a legitimate choice (OIDC Core §3.1.3.7), but Veriqa's signature goes unchecked on the TYPO3 side. </Warning> The extension does not send `nonce` and does not check `iss` — its only replay protection is `state` plus its own context cookie. For a strict RP that is weaker than Drupal. **`oidcEndpointUserInfo` is mandatory.** The fallback path that reads the identity out of the `id_token` carries an extension defect that raises a fatal error on PHP 8 instead of signing the user in. Leaving the field empty is not an option. ## Email and the username TYPO3, like WordPress, **does not require an email**: the default `plugin.tx_oidc.mapping.fe_users` mapping does not map it at all. A messenger sign-in creates a working `fe_user` without the synthetic `.invalid` addresses that Drupal and Bitrix need. The price is the usual one: by default `username` is `<sub>`, that is `telegram:123456789`. A human-readable name comes from overriding the mapping to `<preferred_username>`. The identity is linked by the Veriqa `sub` — if the way it is composed on the Veriqa side ever changes, previously created users stop being recognized. ## Limitations Verified with the **Telegram** channel; TYPO3 with Email, WhatsApp or the other channels has not been tested. # WordPress Source: https://veriqa.app/docs/integrations/wordpress Signing in to WordPress through Veriqa on the stock openid-connect-generic plugin: settings, the mandatory scope step, and what the resulting account looks like. Users sign in to WordPress through Veriqa with the **stock** plugin — neither the Veriqa core nor its distribution changes for a CMS. Everything needed lives on the site side: the plugin, its settings, and one adjustment to the request shape described below. <Note> Verified: WordPress 6 + `openid-connect-generic` 3.11.3; Telegram channel. Working: sign-in, automatic user creation, `id_token` signature checked against JWKS. WordPress with Email, WhatsApp or the other channels has not been tested. </Note> ## What you need * a running Veriqa server reachable from the site (in production: a public HTTPS address with a valid certificate); * a client registered in Veriqa: `client_id`, `client_secret`, and a `redirect_uri` of the form `https://your-site/wp-admin/admin-ajax.php?action=openid-connect-authorize`; * administrator access in WordPress. The client must be **confidential** — one with a secret. Veriqa lets such a client sign in without PKCE, and that is what makes off-the-shelf CMS plugins workable. ## 1. The plugin Install `openid-connect-generic` (10k+ installs, actively maintained). The alternatives do not fit: `nicko170/wp-openid` has not shipped since 2023, the Scouting plugin is pinned to someone else's issuer, and miniOrange keeps the settings you need behind its paid edition. ## 2. Plugin settings Take the addresses from your server's discovery document (`https://veriqa.example.com/.well-known/openid-configuration`): | Plugin field | Value | | - | - | | `endpoint_login` | `/connect/authorize` | | `endpoint_token` | `/connect/token` | | `endpoint_userinfo` | `/connect/userinfo` | | `endpoint_jwks` | `/.well-known/jwks` | | `client_id`, `client_secret` | from the client registration | | `scope` | `openid profile email` (plus `channel` if the RP needs the typed channel claims) | Four settings deserve to be named explicitly — they decide what the account looks like: * **Leave `identity_key` alone.** On the default the login is human-readable (`alice`); setting it to `sub` by hand produces a login like `telegram123456789`. * **`nickname_key` → `preferred_username`**, **`displayname_format` → `name`**. * **Turn `enable_logging` on**, at least while you set things up: the plugin log turns hours of troubleshooting into minutes. * **Leave `no_sslverify` off.** TLS verification should work; if it gets in the way, the problem is the certificate, not the check. ## 3. Drop `scope` from the code exchange This step is mandatory, and it is worth understanding rather than just applying. The plugin puts `scope` into the `grant_type=authorization_code` request, where [RFC 6749 §4.1.3](https://www.rfc-editor.org/rfc/rfc6749#section-4.1.3) does not provide for it: the granted scope comes from the authorization code itself. Keycloak, Auth0 and Azure silently ignore the extra parameter — a strict OpenIddict server rejects the request (OpenIddict issue \#1729 is closed as *invalid*). <Note> Removing the parameter makes the request **conform** to the standard. It is not a way around a check and not a weakening of your security: what gets removed is something that should not be in the request at all. </Note> There are two options and they are interchangeable — **you do not need both**. Which one fits depends on whose side you can change: the site, or the Veriqa deployment. <CardGroup> <Card title="The site owner fixes it" icon="wordpress"> A six-line mu-plugin on the WordPress side. The Veriqa server is untouched — the right choice when Veriqa is not yours (the cloud, or someone else's deployment). </Card> <Card title="The Veriqa owner fixes it" icon="server"> The `drop-scope-on-code-exchange` compatibility quirk — a relaxation scoped to a single `ClientId` on the server side. The right choice when there are many sites and touching each one is not practical. </Card> </CardGroup> ### Option A — the mu-plugin Drop the file into `wp-content/mu-plugins/` (create the directory if it is missing). Must-use plugins load automatically — there is nothing to activate in the admin area. ```php wp-content/mu-plugins/veriqa-oidc-compat.php theme={null} <?php /** * Plugin Name: Veriqa OIDC compatibility * Description: Removes the scope parameter from the OpenID Connect Generic token request. */ declare(strict_types=1); add_filter( 'openid-connect-generic-alter-request', static function (array $request, string $operation): array { if ('get-authentication-token' === $operation) { unset($request['body']['scope']); } return $request; }, 10, 2 ); ``` The change is surgical: `scope` is dropped only for the `get-authentication-token` operation. It stays on token refresh, where RFC 6749 §6 does allow narrowing the scope. ### Option B — the quirk on the Veriqa side The same parameter, removed by the server, for one client. Enabling it takes two steps, and Veriqa logs a warning about every enabled quirk at startup: ```json appsettings.json theme={null} { "Veriqa": { "OpenIddict": { "CompatibilityQuirksAllowApplicationOverride": true, "Clients": [ { "ClientId": "wordpress-site", "CompatibilityQuirks": [ "drop-scope-on-code-exchange" ] } ] } } } ``` The details, the removal condition and the `wordpress-openid-connect-generic` profile are in [Client compatibility](/docs/guides/client-compatibility). With neither option in place the sign-in fails at the code exchange, and WordPress sends the browser back to `wp-login.php?login-error=invalid_request&message=…`. ## What you get The plugin log shows `login-success Successful login for: telegram123456789`; the click-to- signed-in time is around twenty seconds, including everything the person does on their phone. The user is created automatically. **The account is created with an empty email** — Telegram does not hand out addresses and never will. If your site requires one, there are two paths: use the Email channel (the address then arrives in the claims), or have WordPress ask for it after the first sign-in. ## Limitations * Verified with the **Telegram** channel. On the Email channel the profile looks different: `name` repeats the address instead of carrying a human-readable name, so a recipe tuned on Telegram produces a different-looking account. * A dedicated "Veriqa for WordPress" plugin is not needed: the stock plugin covers the job end to end. * The avatar is not part of this recipe: `picture` is a `data:` image URI issued only for the `avatar` scope and only through userinfo, and the plugin setup above does not request that scope. # Configuration reference Source: https://veriqa.app/docs/reference/configuration Every Veriqa configuration section, key and default in one place. Veriqa reads the host's standard `IConfiguration`, so every setting can come from `appsettings.json`, environment variables, user-secrets or any other provider. Everything Veriqa owns lives under a single root key, `Veriqa`: | Section | Contents | | - | - | | `Veriqa` | The auth server: OIDC, transactions, sign-in page, limits, infrastructure | | `Veriqa:Channels` | The channel adapters: Telegram, WhatsApp, other channels, Email | This reference answers "which setting controls it". Two neighboring questions have pages of their own: what the specifications give you is in [OIDC and OAuth 2.0 support](/docs/reference/oidc-capabilities), and what is missing is in [Known limitations](/docs/reference/limitations). In environment variables, `:` becomes `__` — `Veriqa:OpenIddict:Database:Provider` is `Veriqa__OpenIddict__Database__Provider`. One shell limitation is worth knowing here. A POSIX shell allows only letters, digits and `_` in a variable name, so `export` rejects the whole name as soon as **any** segment of the path carries a `-` or a `.`: `export Veriqa__ChannelDisplay__Hints__my-channel=...` is turned down as an invalid identifier. Where that segment comes from makes no difference. It may be a **value** you choose — the channel type in `Veriqa:AuthPageDesign:QrCode:PixelsPerModuleByChannel:<channel type>` and in `Veriqa:ChannelDisplay:Hints:<channel type>` (the channel type of an SPI adapter is allowed a hyphen), or the record code in the `Veriqa:UiConfigurations:Records` catalog. It may just as well be a **fixed key name** of the schema, which you do not choose at all — the message kind in `Veriqa:MessageTemplates:confirmation-prompt`. The variable name itself is perfectly legal, and the limitation is worked around with the usual tools: Docker (`-e`), Kubernetes variables (`env:`), `launchSettings.json` and a plain `env NAME=VALUE command` all accept such names, and .NET reads them as usual. A path whose segments hold only letters, digits and `_` is set from an environment variable in the usual way. **A configuration directory of its own.** One variable does not name a setting but a place to read settings from: `VERIQA_CONFIG_DIRECTORY` points at a **directory**, and the host layers the `appsettings.json` and `appsettings.<Environment>.json` found there over the files shipped with the program and under the environment variables. Both files are optional; a directory that does not exist prevents startup, an empty one is not an error. This is how an installation made by the [OS-service installer](/docs/quickstart/os-service) keeps its configuration — `/etc/veriqa` on Linux, `%ProgramData%\Veriqa` on Windows — where replacing the program cannot touch it. <Note> Configuration is validated at **startup**. An unknown enum value, a relational provider without a connection string or an enabled channel missing its token stops the application immediately rather than failing later on a user's first sign-in. </Note> ## Veriqa:OpenIddict OIDC server, its store and the registered clients. ### Database | Key | Default | Meaning | | - | - | - | | `Provider` | — (no default) | Store for clients, authorizations and tokens: `InMemory`, `PostgreSQL` or `SqlServer` | | `ConnectionString` | — | Required for any relational provider | `Provider` has no default: an unset value is not "`InMemory` by default", it is a choice nobody made. Outside the `Development` environment such a host **fails to start** (the startup check names the fix); in `Development` it starts with a `Warning`. `InMemory` remains a valid value, but it is set explicitly and every restart invalidates issued tokens — fine for development and for a [single-instance connector](/docs/guides/storage#single-instance-connector-without-a-database). In embedded mode these keys do not select the provider: there your host selects it by calling `authServer.UseOpenIddictDatabase(...)` or `authServer.UseInMemoryOpenIddictStore()`; in self-hosted mode the choice is set with these keys. ### Server | Key | Default | Meaning | | - | - | - | | `Issuer` | host URL | Explicit issuer; needed when the public URL differs from the bound one | | `AccessTokenLifetimeSeconds` | `3600` | Access token lifetime | | `RefreshTokenLifetimeSeconds` | `1209600` | Refresh token lifetime (14 days) | | `AuthorizationCodeLifetimeSeconds` | `300` | Authorization code lifetime | | `EnableRefreshTokenRotation` | `true` | Issue a new refresh token on each use | | `EnableRevocation` | `true` | Enable `/connect/revoke` | | `TokenPruneIntervalSeconds` | `3600` | How often the background job deletes stale token and authorization records from the OpenIddict database. Must be greater than 0 and not exceed `4294967` (about 49.7 days, the longest wait a timer accepts), otherwise the host does not start | | `TokenPruneThresholdSeconds` | `86400` | Minimum age of a token or authorization record, counted from its creation, before the job may delete it — and only once the record is no longer valid (expired or revoked). Valid records are never deleted, whatever the value. Must be greater than 0, otherwise the host does not start | | `SigningCertificatePath` / `SigningCertificateBase64` / `SigningCertificatePassword` | — | Token signing certificate. Required outside `Development` (`Path` or `Base64`) | | `EncryptionCertificatePath` / `EncryptionCertificateBase64` / `EncryptionCertificatePassword` | — | Token encryption certificate. Required outside `Development` (`Path` or `Base64`) | The behavior depends on the environment, and the difference matters: * **`Development`** — with no explicit certificates, development keys are generated. Convenient locally, but every restart produces new keys and multiple instances do not share them. * **Any other environment** — there are no development keys: the server **refuses to start** until **both** certificates (signing and encryption) are configured. There is no fallback to generation. The shipped Docker image runs with `ASPNETCORE_ENVIRONMENT=Production`, so it follows the second rule. If you see generated development keys in a deployed installation, the process is running in `Development` — worth fixing before rollout. ### Clients `Veriqa:OpenIddict:Clients` is an array of the applications allowed to sign users in: | Key | Meaning | | - | - | | `ClientId` | Client identifier. Unique within the array: two entries sharing a `ClientId` prevent startup with a validation error — otherwise everything in the second entry would silently not apply | | `DisplayName` | Name of this application in the texts of a sign-in the user receives in the channel and in the email — what fills their `{app}` slot. If unset or blank, the `ClientId` is used instead. It does not apply to the confirmations your server creates (`POST /api/transaction/confirmation`): there `{app}` is always the `ClientId`. The sign-in page does not show it: the name beside the logo there is stated by `BrandName` | | `AllowedRedirectUris` | Exact-match list; no wildcards, absolute URIs only | | `AllowedScopes` | Scopes the client may request. With a non-empty [`Veriqa:ScopesClaims:Scopes`](#veriqascopesclaims) only the scopes of that catalog and `openid` — a scope outside it prevents startup: an application narrows the catalog, it does not widen it | | `AllowRefreshTokens` | Whether `offline_access` is honored | | `AllowClientCredentials` | Whether the client may use the Client Credentials grant — the way your server authenticates to create confirmation transactions (`POST /api/transaction/confirmation`) and read their result. Off by default. Confidential clients only (with a `ClientSecret`): enabling it on a public client prevents startup | | `AllowIntrospection` | Whether the client may call the introspection endpoint `/connect/introspect` (RFC 7662). Off by default. Confidential clients only (with a `ClientSecret`): enabling it on a public client prevents startup. The response carries metadata only, and a client with this permission sees `sub` and `client_id` of tokens issued to other clients — grant it only to your own confidential clients. See [OIDC and OAuth 2.0 support](/docs/reference/oidc-capabilities) | | `AllowConfirmationTokenGrant` | Whether the client may exchange a confirmed confirmation transaction it created for the `id_token` of the person who confirmed (grant `urn:veriqa:params:oauth:grant-type:confirmation`). Off by default. It is the integrator's permission: no parameter of the application's request widens what is disclosed, and the claims stay within `AllowedScopes`. Requires `AllowClientCredentials` and `openid` in `AllowedScopes` — without either, the host does not start. See [Linking from your server](/docs/scenarios/channel-linking#linking-from-your-server-without-a-sign-in) | | `AllowCollectedClaimsDeletion` | Whether the client may delete the collected claims of a subject with `DELETE /api/collected-claims` (the endpoint exists only when a collected claims store is registered). Off by default. It deletes the personal data of any subject of the client's tenant, so grant it to one of your own server clients only; the endpoint has no rate limit of its own. Requires `AllowClientCredentials` — without it, the host does not start. See [Storage](/docs/guides/storage#deleting-by-sub) | | `TenantId` | Tenant this application belongs to. If unset or blank, the entry states no tenant and the transaction is created without one, which is how a self-hosted installation with a single tenant works. The value is read once per request, by each request that knows the client but has no transaction to take the tenant from: the sign-in and the server-to-server creation of a confirmation transaction store it on the transaction they create, and it travels on the transaction from there; the `form_post` response page is written from the HTTP request alone, without the transaction, and reads it again. The tenant is the level at which configuration keys resolve, **not** an access boundary: isolation is provided by `client_id`, which sits above the tenant | | `RequirePhone` / `RequireEmail` | Whether this application requires `phone_number` / `email` — the same shorthand as in [`Veriqa:ScopesClaims`](#veriqascopesclaims). If unset, the global value applies. Stricter only: `false` here does not lift a requirement the global section states | | `ClaimLevels` | Map of claim name → level (`IfAvailable`, `Optional`, `Required`) that RAISES the level the catalog row of that claim states, for this application only; a lower level changes nothing. Raising a claim Veriqa cannot complete (see `Level` in [`Veriqa:ScopesClaims`](#veriqascopesclaims)) prevents startup | | `ClaimCompletionFormEnabled` | Whether Veriqa shows its own claims completion form to the users of this application. If unset, `Veriqa:ClaimCompletion:FormEnabled` applies, and the form is on unless the global section states otherwise. `false` — a missing claim other than `phone_number` and `email` is issued only when the channel supplies it and never blocks the sign-in. Turn it off for a relying party with a profile completion form of its own — see [Keycloak](/docs/integrations/apps#keycloak) | | `ClaimCompletionFormSurface` | Where Veriqa shows its claims completion form to the users of this application: `Auto`, `InitiatingPage`, `CrossDevicePage` or `CrossDevicePageOnly`. If unset, `Veriqa:ClaimCompletion:FormSurface` applies, `Auto` unless the global section states otherwise. `Auto` shows the form on the device that confirmed the sign-in when the sign-in was confirmed on the confirmation page of an Email Pull email (that page confirms it only with `LoginConfirmation.Mode` set to `None`; otherwise the sign-in window does), or when the channel account has no identifier to show (a Telegram account without a username), and on the sign-in page otherwise. `InitiatingPage` — always the sign-in page. `CrossDevicePage` — the device that confirmed the sign-in: the confirmation page of the email, or a page opened by a link button the channel sends into the chat; when the channel cannot show a link button, the issuer is not an absolute HTTPS URL or the button is not delivered, the form falls back to the sign-in page. `CrossDevicePageOnly` — the same without the fallback: a missing required claim then fails the sign-in with `required_claim_unobtainable`, and a missing optional one is skipped | | `ClaimCompletionEmailMethods` | Ways this application admits of obtaining a missing `email`: `Push`, `Code`, `MagicLink` and `Unverified`. If unset, the list of the tenant applies, or else `Veriqa:ClaimCompletion:EmailMethods` (`Push`, `Code`, `MagicLink` unless the global section states otherwise). Narrowing only: a method the list above does not name is dropped, and the order stays that of the list above. The first way available on the page is offered first, the others as alternatives: `Push` only on the device that confirmed the sign-in and with inbound email of the Email adapter, `Code` and `MagicLink` with its outbound email, `Unverified` always — the address is then not confirmed and `email_verified` is `false`. `Code` and `MagicLink` are not two ways the user picks between: together they stand for one way, "Confirm via email", with one button, placed where the first of the two stands in the list. The list decides what its email carries: `Code` alone — a code to enter on the page (`claim-code-mail`); `MagicLink` alone — a link to a confirmation page (`claim-link-mail`); both — one email with a code and a link (`claim-code-link-mail`), and the address is confirmed by whichever the user does first. Two emails of this kind are not sent to one sign-in sooner than `Veriqa:Channels:Email:MailResendInterval` apart. With no method available a required `email` is not enforced; when this application requires the email and no method is available on the sign-in page, the host does not start. An empty list is refused — lower the requirement of the email instead | | `ClaimCompletionEmailLimits` | Limits of the emails sent to obtain `email`: `CodeAttempts` (attempts per code, 5), `MailsPerTransaction` (3), `MailsPerSubject` (per channel account in 24 hours, 10), `MailsPerAddress` (per recipient address in an hour, 5), `MailsPerDomain` (per recipient domain in an hour; `0` — off, the default). If unset, the value of the tenant or of `Veriqa:ClaimCompletion:EmailLimits` applies. Stricter only: a larger value here does not lift a limit set above, and `0` does not switch off a domain limit set above | | `CompatibilityQuirks` | Compatibility quirk keys from the closed registry — relaxations of the request form for this client. Unset by default; effective only while the gate below is open. See [Client compatibility](/docs/guides/client-compatibility) | | `LogoUrl` | Logo of this application's sign-in page; as in the global section, shown in every preset but `Minimal`. If unset, the logo comes from the level below; with neither a logo nor a brand name at any level the page shows the product's own mark | | `BrandName` | Brand name beside the logo of this application's sign-in page; as in the global section, shown in every preset but `Minimal`. If unset or blank, the name comes from the level below. Resolved on its own, so this entry may state the name while the logo comes from elsewhere | | `FooterHtml` | Footer block of this application's sign-in page — your own markup at the bottom of the card. If unset or blank, the block comes from the level below: a blank value here does not switch off the block the global section states. Markup outside the admitted shape drops this client entry from the effective configuration **whole**, so every other setting of the entry then resolves from the level below — the shape and the outcomes are in [Footer block of the sign-in page](/docs/guides/customization#footer-block-of-the-sign-in-page) | | `HopInstructionHtml` | Instruction this application's sign-in page shows in place of the channel choice once the user has continued on the phone in hop mode — your own markup instead of the core's text. If unset or blank, the instruction comes from the level below. The shape and the outcome of an inadmissible value are those of `FooterHtml`: the client entry is dropped **whole** — see [Instruction after the scan](/docs/guides/customization#instruction-after-the-scan) | | `AuthPage:Texts` | This application's wording of the built-in texts of the pages Veriqa renders: a map "built-in text → replacement", keyed by the English text of the element. A value is a replacement key that goes through the locale files, or an object of ready texts by language (`{ "en": "…", "ru": "…" }`) that bypasses them. An entry outranks the tenant's entry for the same text and yields to the `ui_config` record's; entries for other texts stated there stay in force. A text stated by its own setting (`BrandName`, `FooterHtml`, `HopInstructionHtml`, the `Title`/`Instruction` of a record) is not reworded. An entry for a text outside the open list, or a replacement whose slots differ, is not applied and is reported once — at startup and on a reload of the configuration; the client entry is not dropped for it. The forms, the rules and the open list are in [Rewording a page element for one application](/docs/guides/customization#rewording-a-page-element-for-one-application) | | `PrimaryColor` | Accent color of this application's sign-in page, `#RRGGBB`; applied only with `Preset: Branded`. If unset, the color comes from the level below | | `LogoUrlByTheme` | This application's logos for a single theme: a map `light`/`dark` → URL. A theme without a value of its own takes the `LogoUrl` of the same entry | | `PrimaryColorByTheme` | This application's accent colors for a single theme: a map `light`/`dark` → `#RRGGBB`. A theme without a value of its own takes the `PrimaryColor` of the same entry | | `CustomCssPath` / `CssSriHash` | Custom stylesheet of this application's sign-in page and its SRI hash. Resolved as a pair: the entry that states the path owns the hash as well; no path — both come from the level below | | `CustomJsPath` / `JsSriHash` | Custom script of this application's sign-in page and its SRI hash — the risky mode. Effective only while the gate [`Veriqa:AuthPageDesign:CustomJsAllowApplicationOverride`](#veriqaauthpagedesign) is open; with the gate closed the script comes from the level below and a warning is written to the log at startup. Resolved as a pair, exactly like the CSS | | `QrCode` | The sign-in page QR code for this client: the same keys as in the [`Veriqa:AuthPageDesign:QrCode`](#veriqaauthpagedesignqrcode) section, each of them optional. If unset, the QR comes from the level below | | `ChannelButton` | The channel buttons of this application's sign-in page: `ShowChannelButton` — `Always`, `MobileOnly` or `Never`, as in the [global section](#veriqaauthpagedesign). If unset, the value comes from the level below. A pair with the effective `ShowQrCode` that leaves a device with neither the QR nor the button is not applied: the buttons are shown on every device and a warning goes into the log — see [Allowed combinations](/docs/guides/customization#allowed-combinations-of-the-two-visibilities) | | `ChannelLayout` | How several channels share this application's sign-in page: `Tabs` or `Grid`. If unset, the layout comes from the level below. See [Channel layout](/docs/guides/customization#channel-layout) | | `Cancel` | The "Cancel sign-in" button of this application's sign-in window: `Enabled`, `true` or `false`. If unset, the value comes from the level below, and with no level stating it the button is on. See [Cancel button](/docs/guides/customization#cancel-button) | Next to the clients array sits the deployment gate: | Key | Default | Meaning | | - | - | - | | `CompatibilityQuirksAllowApplicationOverride` | `false` | While closed no quirk takes effect, whatever the clients list | ## Veriqa:TransactionEngine The sign-in transaction lifecycle. | Key | Default | Meaning | | - | - | - | | `TransactionTtlSeconds` | `300` | How long the user has to confirm | | `ConfirmationFinalizationTimeoutSeconds` | `30` | Window to finalize after confirmation | | `CompletedRetentionSeconds` | `600` | How long a dead transaction — completed, failed or expired — stays readable before removal | | `CleanupIntervalSeconds` | `60` | Expired-transaction sweep interval | | `CleanupBatchSize` | `100` | Rows per sweep | | `CleanupErrorRetryDelaySeconds` | `10` | Retry delay after a failed sweep | | `MaxInMemoryEntries` | `10000` | Cap for the in-memory store | | `RejectBots` | `true` | Reject transactions started by bots | | `RejectBotsAllowApplicationOverride` | `false` | Let the application override that decision | ### Store | Key | Default | Meaning | | - | - | - | | `ConnectionString` | — | Set it to switch from in-memory to a durable store: a relational connection string, or a Redis one with `Provider` = `Redis` | | `Provider` | `PostgreSQL` | `PostgreSQL` or `SqlServer` (EF Core), or `Redis`. Without `ConnectionString` the store stays in memory whatever the value | | `KeyPrefix` | `veriqa:tx:` | Transaction key prefix, read only on `Redis` | See [Storage](/docs/guides/storage) for migrations and retention. ## Veriqa:ScopesClaims Which scopes exist, which claims they carry and which claims a sign-in requires. | Key | Default | Meaning | | - | - | - | | `Scopes` | — | Catalog of scopes, each with its `Name` and its `Claims` (keys below). If empty, the product's own scopes are registered: `openid`, `profile`, `email`, `phone`, `offline_access`, `channel`, `avatar` | | `RequirePhone` | `false` | Require `phone_number` — a shorthand for `Level: Required` on its claim. An application may require it on its own (`RequirePhone` of the client entry) but cannot lift it. It applies to a sign-in that asks for the `phone` scope: such a sign-in is offered only the channels able to provide the phone number, and with none of them available `/connect/authorize` answers `400` `no_channel_for_required_phone` | | `RequireEmail` | `false` | Require `email` — the same shorthand for the email | <Warning> A non-empty `Scopes` **replaces** the product's own set of scopes as a whole: `scopes_supported` of discovery then lists exactly the scopes of the catalog (plus `openid`, which is always registered), and a request for any other scope is refused with `invalid_scope`. If your clients use `offline_access`, `channel` or `avatar`, list them in the catalog explicitly. </Warning> Keys of a claim in `Claims`: | Key | Default | Meaning | | - | - | - | | `Name` | — | Claim name as it appears in the token. One claim — one row. A claim of a standard scope (`name` of `profile`, `phone_number` of `phone`, `email` of `email`, …) may stand only under that scope | | `Level` | `IfAvailable` | Requirement level: `IfAvailable` — issued only if the channel or the store already has it; `Optional` — the user is asked and may decline; `Required` — the sign-in does not complete without it. A level above `IfAvailable` needs a claim Veriqa can complete — `phone_number`, `email` or a row with `Completion`; on any other claim it prevents startup. An application may raise the level (`ClaimLevels` of the client entry), never lower it | | `Source` | `Channel` | `Channel` — issued by the channel; `External` — added by your claims mapper after Veriqa; `Collected` — collected by Veriqa: never issued by a channel, taken only from the store or through completion, so it requires `Completion` | | `Completion` | — | Description of the completion field: `Label` — the field label, a localization key, must not be blank; `MaxLength` — `256` by default, must be positive, applied to `Text` only; `Format` — `Text`, `Date` or `Choice`; `Options` — the values a `Choice` field offers, in the order listed, each a pair of `Value` (what the claim receives) and `Label` (the caption, a localization key like the field label). A `Choice` field is shown as a drop-down list with an empty first entry, and a value outside the list is refused. `Options` must not be empty for `Choice`; every `Value` is non-blank, has no white space at its edges, is at most 256 characters long and is stated once; every `Label` is non-blank; `Options` on a field of another format prevents startup. Admitted on `Channel` and `Collected` claims. Prevents startup on an `External` claim, on `phone_number` and `email` (they have their own paths) and on the claims the core issues itself: `sub`, `amr`, `auth_time`, `zoneinfo`, `updated_at`, `email_verified`, `phone_number_verified`, `channel_type`, `channel_user_id`, `is_bot`, `veriqa_channel`, `veriqa_email_mode` | ## Veriqa:AuthPageDesign Look and feel of the sign-in page — full walkthrough in [Configuration & customization](/docs/guides/customization). | Key | Default | Meaning | | - | - | - | | `Preset` | `Default` | Design preset; `Branded` enables `PrimaryColor` | | `Theme` | `Auto` | `Auto`, `Light` or `Dark` | | `LogoUrl` | — | Your logo, in every preset but `Minimal`. With neither `LogoUrl` nor `BrandName` stated the page shows the product's own mark | | `BrandName` | — | Brand name beside the logo, in every preset but `Minimal`. Unlike the logo it is cut by no theme — one name for both | | `FooterHtml` | — | Footer block of the sign-in page: your own markup (legal links, a note, a contact) at the bottom of the card, in every preset. Markup outside the admitted shape prevents startup — the shape is in [Footer block of the sign-in page](/docs/guides/customization#footer-block-of-the-sign-in-page) | | `HopInstructionHtml` | — | Instruction the sign-in page shows in place of the channel choice once the user has continued on the phone in hop mode: your own markup, in the shape of `FooterHtml`. If unset, the core's own text is shown. Markup outside the admitted shape prevents startup — see [Instruction after the scan](/docs/guides/customization#instruction-after-the-scan) | | `PrimaryColor` | — | Accent color, `#RRGGBB`; applied only with `Preset: Branded` | | `LogoUrlByTheme` | — | Logo for a single theme: a map `light`/`dark` → URL. A theme without a value of its own takes `LogoUrl` | | `PrimaryColorByTheme` | — | Accent color for a single theme: a map `light`/`dark` → `#RRGGBB`. A theme without a value of its own takes `PrimaryColor` | | `CustomCssPath` / `CustomJsPath` | — | External assets; a custom CSS path overrides the preset | | `CssSriHash` / `JsSriHash` | — | Subresource Integrity hashes for those assets. A hash is resolved together with its path — both always come from the same level | | `CustomJsAllowApplicationOverride` | `false` | Gate for the risky mode: lets a client entry (`Veriqa:OpenIddict:Clients`) state its own `CustomJsPath`/`JsSriHash`. Without it only this section supplies the script | | `ChannelButton:ShowChannelButton` | `Always` | On which devices the button that leads into a channel ("Open in …", "Send confirmation email") is shown: `Always` — on every device; `MobileOnly` — on a phone only; `Never` — nowhere, the QR is the only way in. A pair with `ShowQrCode` that leaves a device with neither the QR nor the button is not applied: the buttons are shown on every device and a warning goes into the log — see [Channel button visibility](/docs/guides/customization#channel-button-visibility) | | `ChannelLayout` | `Tabs` | How several channels share a page that shows a QR for each of them: `Tabs` — one at a time behind tabs; `Grid` — all at once, side by side. A page of one channel, a page laid out as channel buttons and a page with one QR code for all its channels — with or without the "Any channel" tab — are the same under either value — see [Channel layout](/docs/guides/customization#channel-layout) | | `Cancel:Enabled` | `true` | Whether the sign-in window offers the "Cancel sign-in" button, which ends the sign-in and returns the user to the application with `error=access_denied`. On by default — see [Cancel button](/docs/guides/customization#cancel-button) | `ChannelButton`, `ChannelLayout` and `Cancel` are stated on the same ownership levels as the [`QrCode`](#veriqaauthpagedesignqrcode) section below — the `ui_config` record → the client (`Veriqa:OpenIddict:Clients[]`) → this global section — and the nearest level that states a key wins it on its own. ### Veriqa:AuthPageDesign:QrCode The QR code on the sign-in page: whether to show it and at which resolution to draw it. | Key | Default | Meaning | | - | - | - | | `ShowQrCode` | `DesktopOnly` | Whether the QR is shown: `Always` — on every device; `DesktopOnly` — everywhere but a phone, which gets the channel buttons instead; `Never` — only the channel buttons, on any device | | `PixelsPerModule` | `6` | QR resolution — pixels per module; allowed range `5`…`35` | | `PixelsPerModuleByChannel` | — | Map "channel type → resolution" **of the same level**, read before that level's `PixelsPerModule`; a channel absent from the map takes the `PixelsPerModule` of the same level | The section is stated on three ownership levels, and the nearest level that states it wins: the `ui_config` record → the client (`Veriqa:OpenIddict:Clients[].QrCode`) → this global section. Showing the QR and its resolution are two different settings, and a level wins each of them on its own: a client that states only `ShowQrCode` keeps the global resolution. The resolution itself is ONE setting stated per channel: `PixelsPerModuleByChannel` and `PixelsPerModule` are two entries of the same level, and a level exhausts both of them before the lookup moves on to the level below. What that means in practice: a client that states only `PixelsPerModule` wins the resolution **for every** channel — the per-channel map of the global section is never reached. To keep a channel's own resolution, name it in the map of the same level that states `PixelsPerModule`. An example. The global section states `PixelsPerModule: 6` and a `PixelsPerModuleByChannel` entry `telegram: 14`, while the `app-client` client states only `PixelsPerModule: 8`: | Channel | Resolution for `app-client` | | - | - | | `telegram` | `8` — the client has no map, so its `PixelsPerModule` wins and the global section's map is never read | | `whatsapp` | `8` — for the same reason | A `PixelsPerModule` outside the range on the client or `ui_config` level does not apply — the resolution comes from the level below and the host still starts. Such a value is reported by ONE warning in the log per configuration snapshot: the whole catalog of values (the global section, the client entries, the `ui_config` records) is checked at startup and on every configuration reload, rather than on every request. In the global section such a value remains a deployment error and prevents startup. ## Veriqa:Localization and Veriqa:ChannelDisplay | Key | Default | Meaning | | - | - | - | | `Localization:DefaultLanguage` | `en` | Sign-in page language when the user has no preference | | `ChannelDisplay:Mode` | `Multichannel` | How channels are presented | | `ChannelDisplay:ChannelOrder` | — | Explicit ordering of channel buttons | | `ChannelDisplay:Hints` | — | Map of channel type to hint text. The hint renders inside its own channel's panel, so with tabs only the selected channel shows one, and with [channel buttons](/docs/guides/customization#channel-buttons) it stands under the button of its own channel. The key is the channel type (`whatsapp`, `telegram`, your SPI channel) and its case does not matter; a channel absent from the map, or one with blank text, gets no hint. The text may carry inline markup — `span`, `strong`, `em`, `a`, `br`; a value outside that shape is shown as plain text, with a warning in the log — see [Markup in the title, the instruction and a channel hint](/docs/guides/customization#markup-in-the-title-the-instruction-and-a-channel-hint) | ## Veriqa:RateLimit Per-endpoint limits, each a permit count over a window in seconds. Defaults: | Endpoint group | Permits / window | | - | - | | `Authorize` | 60 / 60s | | `Token` | 20 / 60s | | `Callback` | 60 / 60s | | `Polling` | 600 / 60s | | `Webhook` | 100 / 60s | | `SignalR` | 30 / 60s | | `EmailStart` | 5 / 60s | | `UserAuth` | 5 / 60s (`UserAuthQueueLimit` 0, `UserAuthMaxPartitions` 50000) | Webhook limits deserve attention when you scale: they apply per instance, and a channel provider that retries aggressively can trip them. ## Veriqa:InitiatorContext Context about the device that started the transaction, shown to the user before they confirm — see [Initiator context](/docs/concepts/initiator-context). | Key | Default | Meaning | | - | - | - | | `Enabled` | `true` | Collect initiator context at all | | `CollectIpAddress` | `true` | Collect the IP address | | `CollectUserAgent` | `true` | Collect the user agent | | `CollectGeoLocation` | `true` | Resolve geolocation | | `StoreRawUserAgent` | `false` | Persist the raw UA string | | `DisplayFields` | see concept page | Which fields the confirmation message shows | | `AnomalyDetection:Enabled` | `false` | Flag unusual sign-in context | | `GeoIp:Provider` / `GeoIp:DatabasePath` | offline | GeoIP source | <Warning> The offline GeoIP path expects a **GeoLite2** database, which Veriqa does not ship. Download it from MaxMind yourself and accept their EULA — the library is Apache-2.0, the license sits on the data. The standalone image already carries the library: mount the database and set `GeoIp:DatabasePath` to switch it on. In your own host it arrives in the satellite package `Veriqa.Core.AuthServer.MaxMind` (`builder.Services.AddMaxMindGeoIp()`). </Warning> ## Veriqa:HopMode Hop mode puts a short one-time link to your Veriqa server into the QR code instead of the channel link. Two of its settings are set node-wide and may be overridden for a single channel: | Key | Default | Meaning | | - | - | - | | `Veriqa:HopMode:Enabled` | `false` | Hop mode on for every channel | | `Veriqa:HopMode:RedirectStyle` | `Interstitial` | How the hop link leads to the channel: `Interstitial` or `Direct`. The Email channel with the emailed link on answers in its own way — see below | | `Veriqa:Channels:{Channel}:HopEnabled` | not set | Overrides `Veriqa:HopMode:Enabled` for this channel | | `Veriqa:Channels:{Channel}:HopRedirectStyle` | not set | Overrides `Veriqa:HopMode:RedirectStyle` for this channel | A channel value, when set, wins over the node-wide one; an absent or empty channel value inherits it. The remaining `HopMode.*` keys and their defaults are listed in [Keys in the declaration catalog](#keys-in-the-declaration-catalog), where `HopMode.Enabled` and `HopMode.RedirectStyle` show "not declared": their defaults are the values above. **One QR code for the whole transaction.** `Veriqa:HopMode:QrMode` decides what a sign-in window or a confirmation page with several channels shows while hop mode is on for at least one of them: | Value | The page shows | | - | - | | `PerChannel` | Tabs, each channel with its own QR code. Default. | | `Unified` | One QR code for the whole transaction and the channel buttons beside it, no tabs; the channel is picked on the phone that scans it. | | `UnifiedWithChannels` | Both: the first tab, "Any channel", holds the one QR code and the buttons of every channel; a tab per channel follows with that channel's own QR code and its button. | A page of one channel, a page under `ShowQrCode: Never` and a phone under `DesktopOnly` show no tabs whatever the value. In a desktop window wide enough for it, a page with the single QR code uses two columns — the QR code on the left, the channel buttons on the right; a narrower window, and a page that shows no channel buttons on the desktop ([`ShowChannelButton`](/docs/guides/customization#channel-button-visibility): `MobileOnly` or `Never`), keep one column. `Veriqa:HopMode:ShowChannelButtonsOnAnyChannelTab` (default `true`) decides, under `UnifiedWithChannels`, whether the "Any channel" tab shows the channel buttons beside the one QR code. With `false` that tab holds the QR code alone and its instruction asks only to scan it; the tab of each channel keeps its button. A phone under `ShowQrCode: DesktopOnly` shows no QR code, so it shows the buttons of every channel whatever the value. Under `PerChannel` and `Unified` the key has no effect. <Tip> **We recommend switching hop mode on.** It gives the best user experience: a lighter QR code that any phone camera reads at once, a "scanned" signal on the desktop page as soon as the phone opens the link, and a launch page where a direct jump into the messenger app is unreliable. It stays off by default for one reason: every hop link is built on `Veriqa:OpenIddict:Server:Issuer`, so with hop on the issuer has to be stated as an absolute `https` address, or the server does not start. The phone also has to reach that address — to try hop mode on your own machine, put Veriqa behind an HTTPS tunnel, see [Testing the integration locally](/docs/integrations/local-testing#hop-mode-on-a-local-setup). Why hop mode is better is covered in [Hop mode](/docs/concepts/hop-mode). </Tip> **The Email channel under hop mode.** The target of the Email channel is an email the phone itself opens — as it is without hop mode. While the sign-in by an emailed link is on (`PullEnabled`), the phone gets one page — the address field and the button that opens the email — under either `RedirectStyle`, and the page never opens the mail client by itself. With the emailed link off, Email follows `RedirectStyle` like any other channel: `Interstitial` gives the launch page, which opens the email by itself and keeps a button in case it did not open; `Direct` redirects straight to the email (`302` to `mailto:`), and that redirect tells the sign-in page that the email is open in the mail app. See [Email under hop mode](/docs/guides/channels#email-under-hop-mode). ### Hop mode for one application The same members can be stated inside the entry of an OIDC client (`Veriqa:OpenIddict:Clients`), and they then apply to that application only: | Member of the client entry | Meaning | | - | - | | `HopMode:Enabled` | Hop mode for this application, for every channel | | `HopMode:RedirectStyle` | How the hop link leads to the channel for this application | | `HopMode:QrMode` | `PerChannel`, `Unified` or `UnifiedWithChannels` for this application | | `HopMode:ShowChannelButtonsOnAnyChannelTab` | Whether the "Any channel" tab shows the channel buttons for this application | | `Channels:{Channel}:HopEnabled` | Overrides `HopMode:Enabled` of the entry for this channel | | `Channels:{Channel}:HopRedirectStyle` | Overrides `HopMode:RedirectStyle` of the entry for this channel | ```json appsettings.json theme={null} { "Veriqa": { "OpenIddict": { "Clients": [ { "ClientId": "portal", "HopMode": { "Enabled": true }, "Channels": { "Email": { "HopEnabled": false } } } ] } } } ``` For each channel the value is taken in this order: the channel value of the client entry → the general value of the client entry → the node (its channel value, then the node-wide one). A client entry that states nothing inherits the node. A value that cannot be read — `"HopEnabled": "maybe"`, or a style name other than `Interstitial` / `Direct` — is skipped with a warning in the log, and the next value in that order applies. A style stated as a number that names neither of the two is skipped the same way, but without the warning. The other hop settings — what is collected about the scanning device, `TokenLifetime` and `HopPath` — are set for the node only. The endpoint behind the hop link (`HopPath`) exists only when hop mode is on at startup for a channel that is enabled on the node — node-wide, for that channel, or in at least one client entry. Client entries are read once at startup: if hop mode was off everywhere and you turn it on in a client entry, restart the host. Whenever hop mode is on, `Veriqa:OpenIddict:Server:Issuer` has to be an absolute `https` URL — in a client entry as well as on the node. ## Infrastructure Flat keys read by the host, mostly relevant when you run more than one instance or behind a proxy: | Key | Meaning | | - | - | | `Veriqa:SignalR:RedisConnectionString` | SignalR backplane; unset means single-instance | | `Veriqa:DataProtection:RedisConnectionString` | DataProtection keys in Redis | | `Veriqa:DataProtection:KeysDirectory` | DataProtection keys on disk (a shared volume) | | `Veriqa:ChannelStores:Redis:ConnectionString` | Channel stores in Redis, shared by every instance; unset means in-process stores. With the transaction store on `Redis` it must equal `Veriqa:TransactionEngine:Store:ConnectionString`, otherwise the host does not start. See [Storage](/docs/guides/storage#channel-stores-multiple-replicas) | | `Veriqa:ChannelStores:Redis:*` | The other parameters of the channel stores under their own names: `PromptKeyPrefix`, `EmailActionTokenKeyPrefix`, `EmailPushCorrelationKeyPrefix`, `ProcessedMessageKeyPrefix`, `EmailClaimCompletionKeyPrefix`, `PhoneRequestCorrelationKeyPrefix`, `LinkHandleKeyPrefix`, `StartCodeKeyPrefix`, `ProcessedMessageRetention` (`hh:mm:ss`) | | `Veriqa:Events:RabbitMq:ConnectionString` | Switches the RabbitMQ event publisher on; unset means no publishing. The other keys of the section: `ExchangeName`, `QueueCapacity`, `MaxReconnectAttempts`, `ReconnectBaseDelaySeconds`. See [Storage](/docs/guides/storage#publishing-transaction-events) | | `Veriqa:CollectedClaims:Store:Enabled` | Collected claims store, `false` by default: `true` keeps the claims Veriqa collected in the transaction store database and requires `Veriqa:TransactionEngine:Store:ConnectionString` — without it the host does not start. It does not combine with `Veriqa:TransactionEngine:Store:Provider` = `Redis`: Redis has no relational database to hold the claims, and the host does not start either. See [Storage](/docs/guides/storage#collected-claims-store) | | `ForwardedHeaders:KnownProxies` / `:KnownIPNetworks` | Trusted proxies behind TLS termination | | `Veriqa:PathBase` | Path prefix the host is deployed under, for example `/auth/veriqa`; unset means the root of the host name. Starts with `/`, does not end with `/`, and has no `?`, `#`, `%`, `\`, whitespace or `.` / `..` segment — another form stops the host at startup. See [Behind a reverse proxy](/docs/guides/reverse-proxy#under-a-path-prefix) | With neither DataProtection option set, keys live in memory and are lost on restart — fine for development, broken for a scaled deployment. <Note> **Redis licensing.** Redis server ≥ 7.4 ships under RSALv2/SSPL (8.x additionally offers AGPL). That does not affect Veriqa's license, but if it affects your policy, **Valkey** (BSD) is a drop-in alternative. </Note> ## Veriqa:Channels Every channel lives under `Veriqa:Channels:{Channel}` and activates only with `"Enabled": true`. Full setup with the provider-side steps is in [Channel setup](/docs/guides/channels). | Section | Key settings | | - | - | | `Veriqa:Channels:Telegram` | `BotToken`, `UpdateMode`, `WebhookBaseUrl`, `WebhookSecretToken`, `BotUsername` | | `Veriqa:Channels:Max` | `BotToken`, `UpdateMode`, `WebhookBaseUrl`, `WebhookSecretToken`, `BotPublicName` | | `Veriqa:Channels:WhatsApp` | `Provider`, `BusinessPhoneNumber`, `MetaCloudApi:*`, `Twilio:*` | | `Veriqa:Channels:Instagram` | `AppSecret`, `WebhookVerifyToken`, `AccessToken`, `AccountId`, `Username`, `GraphApiBaseUrl`, `GraphApiVersion`, `UnaddressedReplyEnabled` | | `Veriqa:Channels:Messenger` | `PageId`, `PageName`, `PageAccessToken`, `AppSecret`, `WebhookVerifyToken`, `GraphApiBaseUrl`, `GraphApiVersion`, `UnaddressedReplyEnabled`, `DeepLinkByPageId` | | `Veriqa:Channels:WeChat` | `AppId`, `AppSecret`, `Token`, `ApiBaseUrl` | | `Veriqa:Channels:Viber` | `AuthToken`, `BotUri`, `SenderName`, `WebhookBaseUrl`, `ApiBaseUrl` | | `Veriqa:Channels:Email` | `PublicBaseUrl`, `PullEnabled`, `PushEnabled`, `TokenTtl`, `MailResendInterval`, `Outbound:*`, `Inbound:*` | Two more keys go into the section of **any** channel, a channel of a third-party adapter included: `SpareStartCodeEnabled` (`bool`, defaults to `true`) switches the spare start code the sign-in page offers for a channel that keeps a working link, and `BotName` (text, not set by default) states the name the user sees the chat under — it wins over the name the channel reports. Both are keys of the core with the channel as a dimension, `Channels.SpareStartCodeEnabled` and `Channels.BotName` in the [catalog](#keys-in-the-declaration-catalog), stated in the configuration of the host or for a tenant. What they change on the pages: [Channel setup](/docs/guides/channels#settings-every-chat-channel-shares). ### InboundVerification — a required key of every channel section `Veriqa:Channels:{Channel}:InboundVerification` declares **how the authenticity of an incoming event of that channel is verified**. The value comes from a closed dictionary: | Value | Meaning | | - | - | | `signature` | The request body is signed with a platform key (HMAC and the like) | | `shared_secret` | The incoming request presents a shared secret (a header, a token) | | `outbound_fetch` | There are no incoming requests — Veriqa fetches the events with an outgoing request of its own | | `none` | The authenticity of an incoming event is not verified | <Warning> **The key is required for every enabled channel.** A deployment where a channel declared at core level is enabled (`"Enabled": true`) and the value is missing or outside the dictionary **does not start**: the message names the channel type, the address of the key and all four admitted tokens. State the value in the sections of disabled channels too — then flipping `Enabled` to `true` does not break startup. The startup check walks exactly the set of channels the core builds its enabled list from: all shipped channels are in it, a channel plugged in through the SPI with a bare `AddChannel` call is not — the key of its section is needed as well, but a missing value does not prevent startup, see [Your own channel adapter](/docs/guides/custom-channel-adapter). </Warning> **You** declare the fact, not the adapter and not the core: Veriqa only sees that a check ran and passed, never what the check was — and deriving the value from the channel type would leave a third-party channel with nothing to describe it. The value for every built-in channel and mode is in [Channel setup](/docs/guides/channels). A channel that fetches its updates itself (`UpdateMode: Polling`) while declaring anything other than `outbound_fetch` produces a `Warning` at startup; startup continues. The declared value reaches the audit record as an attribute of its own, so the audit log shows what a confirmation was protected by. ### OutcomeNotice — where the receipt of an outcome appears `Veriqa:Channels:OutcomeNotice:DisplayIntent` (key `Channels.OutcomeNotice.DisplayIntent`) says **how you want the receipt of a terminal outcome to appear** in the conversation. It is stated over three levels — core, tenant and application — and each of them overrides the one below it freely: the global section sets the intent of the whole installation, a tenant states its own with the member `OutcomeNoticeDisplayIntent` of its record, and a single application with the member of the same name in its OIDC client entry. A `ui_config` record states no intent. Whatever level answers, the value covers the outcome as a whole — a confirmation, a decline and an expiry are all shown the same way. | Value | What it asks for | | - | - | | `ReplacePrompt` | The receipt takes the place of the message that asked the question: the conversation keeps one message, and the buttons go away with the text they belonged to | | `NewMessage` | The receipt arrives as a message of its own; the question stays in the conversation with its wording untouched, and its buttons are taken away where the platform can do that without rewriting the text | The shipped value is `ReplacePrompt` — what the channels did before the setting existed, so a deployment that states nothing sees no change. An unreadable value is read as nothing stated and answers `ReplacePrompt` too; startup is not interrupted. <Note> **This is an intent, not a guarantee.** Telegram and MAX honor it. WhatsApp, Instagram, Messenger, WeChat and Viber always send a new message, and Email shows no outcome receipt at all — as they ship today. An adapter that does not honor the intent is not failing: nothing is logged as an error and the result of the delivery does not change. With `NewMessage` the receipt arrives as its own message and the buttons of the question are taken away as part of the same delivery, where the platform can change a message's buttons without rewriting its text; the wording of the question is never rewritten. Failing to take the buttons away is not a failure of the delivery either — and where a button does survive, pressing it only earns another receipt, because a terminal transaction cannot be replayed. </Note> ## Veriqa:CompletionBehavior What a channel does **after a sign-in is completed**. It is one action, stated for every channel at once or for a channel of its own, and — for the actions that show buttons — what those buttons are. | Key | Default | Meaning | | - | - | - | | `Action` | `None` | The action for every channel that has no entry in `ActionByChannel` of the same level: `None` — nothing is sent after the sign-in; `WelcomeMessage` — the user receives a welcome message in the channel they signed in through; `Menu` — a message with a list of buttons; `Redirect` — a message with one button that opens a link | | `ActionByChannel` | — | Map "channel type → action" **of the same level**, read before that level's `Action`; the channel type is matched case-insensitively. A channel absent from the map takes the `Action` of the same level | | `MenuItems` | `[]` | The buttons of `Menu` for every channel that has no entry in `MenuItemsByChannel` of the same level, in display order. Each item is `{ "Text": "…", "Action": "…" }`. An empty list states nothing | | `MenuItemsByChannel` | — | Map "channel type → items" **of the same level**, read before that level's `MenuItems`; the channel type is matched case-insensitively | | `RedirectUrl` | not set | The address of the `Redirect` button for every channel that has no entry in `RedirectUrlByChannel` of the same level: an absolute URL written as `scheme://…`. May carry the `{handle}` placeholder — [a link handle](#menu-and-redirect-a-message-with-buttons) | | `RedirectUrlByChannel` | — | Map "channel type → address" **of the same level**, read before that level's `RedirectUrl`; the channel type is matched case-insensitively | | `RedirectButtonText` | `Open` | The label of the `Redirect` button — one for every channel | The action is stated on three ownership levels, and the nearest level that states it wins: the client → the tenant → this global section. Above the global section the same members sit in the `CompletionBehavior` section of the level's own record — for an application that is `Veriqa:OpenIddict:Clients[].CompletionBehavior:Action` and `…:ActionByChannel`. A `ui_config` record states no action: this is behavior, not presentation. The shipped value is `None`, so a deployment that states nothing sees no change. A value the setting does not accept never interrupts startup, and how far it reaches depends on its shape. A text that is not one of the names of the setting — in `Action` or in an entry of `ActionByChannel` — leaves the whole level unstated: neither member of that level is read, and the action comes from the level below. A number that names no action discards only the entry it stands in: with `Action: "WelcomeMessage"` and `ActionByChannel: { "telegram": 7 }` on one level, `telegram` takes that level's `WelcomeMessage`. The action is ONE setting stated per channel: `ActionByChannel` and `Action` are two entries of the same level, and a level exhausts both of them before the lookup moves on to the level below. So a client that states only `Action` wins the action **for every** channel — the per-channel map of the global section is never reached. To keep a channel's own action, name it in the map of the same level that states `Action`. ```json theme={null} { "Veriqa": { "CompletionBehavior": { "Action": "None", "ActionByChannel": { "telegram": "WelcomeMessage" } } } } ``` Here a sign-in through `telegram` is followed by a welcome message, and a sign-in through any other channel by nothing. **What a channel performs is a fact of the channel**, not of your configuration: | Channel type | Actions the channel performs | | - | - | | `telegram`, `max` | `None`, `WelcomeMessage`, `Menu`, `Redirect` | | `whatsapp` | `None`, `WelcomeMessage` | | `email` | `None` | | a channel that declares nothing — a custom SPI adapter, a channel connected over HTTP | `None` | An action the channel does not perform is never handed to it. It is replaced by the channel's own default — `None` for every channel in the table — and the replacement is loud: a `Warning` in the log and an audit event `channel_completion_behavior_clamped` whose details read `requested=<action>; applied=<action>`. The sign-in itself completes as usual. The audit record names the tenant and the client of the sign-in — the levels the action may have been stated on — and, as its actor, the masked channel user. It carries the failure outcome, as every channel event does, although the sign-in succeeded. `None` is never replaced: doing nothing is honored on every channel, silently. ### Menu and Redirect: a message with buttons `Menu` and `Redirect` send one message: a text with buttons under it. `Redirect` is a single button that opens a link; `Menu` is a list of buttons, one in a row, in the order you state them. The channels that perform them are in the table above; on any other channel the action is replaced, as described there. ```json theme={null} { "Veriqa": { "CompletionBehavior": { "Action": "None", "ActionByChannel": { "telegram": "Menu" }, "MenuItems": [ { "Text": "Open the app", "Action": "https://app.example.com/home" }, { "Text": "My orders", "Action": "orders" } ] } } } ``` Here a sign-in through `telegram` is followed by a menu of two buttons: the first opens a link, the second is handed to your code. A redirect needs the address only — the label ships as `Open`: ```json theme={null} { "Veriqa": { "CompletionBehavior": { "ActionByChannel": { "telegram": "Redirect" }, "RedirectUrl": "https://app.example.com/home" } } } ``` **The items and the address are stated like the action.** `MenuItems` and `RedirectUrl` live on the same three levels, each next to its own per-channel map, and a level exhausts its map and its plain member before the level below is read — so a client that states only `RedirectUrl` wins the address for every channel. `RedirectButtonText` is one value per level, with no per-channel map. A `ui_config` record states none of them. **The labels are translated, the address is not touched.** `Text` of an item and `RedirectButtonText` are looked up in the locale files of your host in the language of the recipient; a label no file carries is shown as you wrote it. The address is sent exactly as stated: nothing about the user is substituted into it. The text above the buttons is a message of its own — the kind `post-login-menu` or `post-login-redirect`, worded and overridden like any other message: [Veriqa:MessageTemplates](#veriqamessagetemplates). **What makes an item a link.** An `Action` written as an absolute URL with `scheme://` in front — `https://…`, `tg://…` — becomes a link button. Any other value is **callback data**: the button sends it back when pressed. The rule is the same one `RedirectUrl` is checked by. An address without `//` — `mailto:…`, `tel:…` — is therefore read as callback data, not as a link. A value with a space or a line break at either edge is not accepted at all — neither as `RedirectUrl` nor as an `Action`: it is reported like any other value outside the setting's domain, and an item written that way takes its whole list with it. Whether a scheme is allowed on a button is the platform's decision: an address it refuses fails the send, and the message does not go out. **A link can carry a link handle.** Write `{handle}` — in exactly this case — into `RedirectUrl`, an entry of `RedirectUrlByChannel` or the `Action` of a link item, for example `https://t.me/shop_bot?start={handle}`. When the message is sent, every `{handle}` in its addresses is replaced by one single-use value of 22 characters `[0-9A-Za-z]`; your server exchanges it for the identity of the user who signed in — [Two bots](/docs/scenarios/channel-linking#two-bots-handing-the-user-over-to-your-bot). One message carries one handle, valid for `Veriqa:TransactionEngine:CompletedRetentionSeconds` from the moment it is issued. An address without the placeholder is sent as written and no handle is issued; there is no separate setting to switch this on. In callback data `{handle}` is not replaced — the value goes out as written. If the handle cannot be issued — the sign-in belongs to no client, or the handle store refused the record — the buttons that ask for it are not sent and a `Warning` is written: `Redirect` sends nothing, `Menu` goes out without those items. The sign-in is not affected. **A callback item is handed to your code.** The press reaches the built-in channel adapter — the bot is yours and Veriqa runs in the same process — and is passed to the handler you register: ```csharp theme={null} builder.Services.AddScoped<IMenuItemPressHandler, OrdersMenuHandler>(); public sealed class OrdersMenuHandler : IMenuItemPressHandler { public Task HandleAsync(MenuItemPress press, CancellationToken cancellationToken = default) { // press.ChannelType, press.ChannelUserId, press.TenantId (null — the default tenant), // press.Value — the value the pressed button carried: the Action of the item // as it was stated when the message was sent. Check it before acting on it. return Task.CompletedTask; } } ``` `IMenuItemPressHandler` and `MenuItemPress` come with the `Veriqa.Core.Contracts` package. The handler returns nothing: the press is already answered to the platform, and what it means is up to you. The event names no sign-in — the menu is shown after the sign-in is over. The handler is created when an item is pressed, in the scope of that event, and for no other event of the channel. An exception — thrown by its constructor or by `HandleAsync` — is logged and affects neither the user nor any sign-in. **Return quickly.** `HandleAsync` is awaited while the event is being processed. Until it returns the platform is not told that the event was received: a webhook left unanswered is delivered again, and your handler gets the same press twice; in polling mode the events of other users wait behind it. Put anything slow into a background queue of your own and return. **Treat `press.Value` as untrusted input.** A button outlives the configuration that drew it: an item you removed from `MenuItems` can still be pressed in an old message, and Veriqa does not check the value against the items configured today. Check the value, and what this user may do, in your handler. **Without a registered handler callback items are not drawn.** A `Warning` names how many were left out; the link items of the same menu are still sent. A press of a button sent before a restart that dropped the handler is left without processing. **When nothing is sent.** `Menu` with no items stated for the channel, `Menu` whose every item was left out, and `Redirect` with no address stated each write a `Warning` and send nothing; the sign-in completes as usual. A value the setting does not accept never interrupts startup and leaves its entry unstated, so the value comes from the next entry or the level below: a `RedirectUrl` that is not a URL, and a list in which any item has a blank `Text` or `Action` — one such item takes the whole list of that entry out. An empty list states nothing either: to switch a menu off on a level above, state the `Action`, not an empty list. **The platform's budget is checked before the send**, by the limits the platform itself names: | Channel type | Checked before the send | What happens over the limit | | - | - | - | | `telegram` | the payload of a callback button — 64 bytes in UTF-8, **the marker `vq_menu_` included**, which leaves 56 bytes for your `Action` | that button is left out with a `Warning`; the rest are sent | | `max` | the address of a link button — 2048 characters. The platform names no limit for the payload of a callback button, so nothing is checked beforehand | a link button over the limit is left out with a `Warning`. A callback value the platform refuses fails the send: **the whole message does not go out**, not one button | A message none of whose buttons can be drawn is not sent. How a post-completion message behaves — the welcome message and a message with buttons alike: * It follows **every** completed sign-in of a channel that performs the action — confirmed in the channel, confirmed on the web page, or completed by the steps that collect missing claims. A confirmation your server creates is not a sign-in and is followed by nothing. * It arrives as a message of its own, **after** the outcome receipt, and never replaces it. * A failure to send it is logged and does not change the result of the sign-in. * The text of the welcome is the message kind `post-login-welcome` — worded, localized and overridden like any other message: [Veriqa:MessageTemplates](#veriqamessagetemplates). * Delivery is not recorded on the transaction. An event repeated in order earns no second message; the same event processed twice at the same moment may, as the product ships today. In the [catalog table](#keys-in-the-declaration-catalog) the key `CompletionBehavior.Action` shows `not declared` in the Default column: the shipped `None` is applied where no level states the action, the same way as the shipped resolution of `AuthPageDesign.QrCode.PixelsPerModule`. `CompletionBehavior.MenuItems` and `CompletionBehavior.RedirectUrl` show `not declared` for a plainer reason: the product ships no menu and no address. ## Veriqa:MessageTemplates Everything the user reads in the messenger and in email is a **message**. One message is **two** settings rather than one text: the **contract** declares which slots the message has, and the **template** carries the wordings that use those slots. Hence two addresses — one pair per message, not one line per text: | Setting | Address on the `Core` level | | - | - | | Contract | `Veriqa:MessageTemplates:{kind}:[ByType:{transaction type}]:[ByAction:{action type}]:Contract` | | Template | `Veriqa:MessageTemplates:{kind}:[ByType:{transaction type}]:[ByAction:{action type}]:[BySurface:{surface}]:[ByChannel:{channel}]:Templates` | The bracketed groups are optional: a wording that does not narrow along an axis drops that group whole — which is how one address serves every step of the lookup, from the most specific (`{kind, transaction type, action type, surface, channel}`) down to the broadest (`{kind}`). The kind never drops out: "the text of **which** message" is the question itself. One group is not independent: `ByAction` is read **only under `ByType`**. No step of the lookup names an action type without its transaction type, so a wording at `{kind}:ByAction:{action type}` would never be reached — the host refuses to start on it, saying the address is "narrowed by a combination of axes no step of its ladder addresses". A confirmation's action type is therefore worded at `{kind}:ByType:confirmation:ByAction:{action type}`. The kinds the product ships: | Kind | What it is | | - | - | | `confirmation-prompt` | the sign-in confirmation request a channel shows to the user | | `sign-in-mail` | the Email channel's sign-in email: its subject and its body | | `claim-code-mail` | the email with a code that confirms an email address the user entered on the web step of the claims completion | | `claim-link-mail` | the email with a link that confirms an email address the user entered on the web step of the claims completion | | `claim-code-link-mail` | the email with both a code and a link, sent when the list of ways names `Code` and `MagicLink` together; the address is confirmed by whichever the user does first | | `deeplink-prefill` | the pre-filled message the user sends themselves (WhatsApp, Email Push) | | `phone-number-request` | the text of the request for a phone number the user may skip (`Optional`), which the claims completion sends into the user's chat; the labels of its buttons are not part of the message | | `phone-number-request-required` | the same request for a phone number the sign-in cannot finish without (`Required`); a wording stated for one of the two kinds does not answer for the other | | `web-confirmation-notice` | the message a channel sends into the user's chat once a sign-in has started under `LoginConfirmation.Mode` = `OnWebPage`, to send the user to the sign-in page where the question is asked; sent once per sign-in, by the channels that send messages into the chat (Email does not). Ships one wording, at the bare kind: `Sign-in started — confirm it on the sign-in page` | | `outcome-receipt-confirmed` | the outcome receipt for a confirmed decision — the terminal text a transaction answers with | | `outcome-receipt-declined` | the outcome receipt for a declined decision | | `outcome-receipt-expired` | the outcome receipt for an expired transaction | | `outcome-receipt-awaiting-claims` | the state a channel shows while a confirmed sign-in waits for the claims completion; it is not a terminal receipt — the receipt of the outcome follows as a message of its own. Ships one wording, at the bare kind: `Sign-in confirmed. One step left: provide the requested details to finish.` | | `post-login-welcome` | the welcome message a channel sends after a completed sign-in, when `Veriqa:CompletionBehavior` asks for one | | `post-login-menu` | the text above the buttons of the menu a channel sends after a completed sign-in, when `Veriqa:CompletionBehavior` resolves to `Menu` | | `post-login-redirect` | the text above the link button a channel sends after a completed sign-in, when `Veriqa:CompletionBehavior` resolves to `Redirect` | A template's value is a **ladder of steps**, fullest to minimal, as one array. A step is either a string (a text that suits any sink) or a structure `{ "Subject": "…", "Html": "…", "Plain": "…" }`, where `Html` and `Plain` are the **editions** of the step: an email is composed once and carries both of its parts at once. Each edition is optional, but a step must state at least one — otherwise startup fails, naming the message kind and the number of the step. The one shown is the first step that states the edition asked for and has all of its slots filled, so the two parts of one email may come from different steps, and the subject is taken from the step chosen for `Html`. An edition no step states is one the email does not carry at all — that is how an HTML-only email is declared. The minimal step must lean on guaranteed slots only — checked at startup, on **every** step and for **every edition** separately. A declaration replaces the ladder whole: the steps stated at different addresses are never merged. **A wording the product ships may sit at a narrower address than the one you write.** The lookup goes level by level, and a level exhausts every step of the address — from the most specific down to the broadest — before the resolution drops to the level below. The shipped declarations are the fallback of the `Core` level, read at the **same** addresses; and `Veriqa:MessageTemplates` in your `appsettings.json` **is** that level. So a declaration of yours placed at a broader address than the shipped one is never reached: the narrower step answers first, and it answers from the product. Nothing fails and nothing is logged — your configuration is valid, it is simply never asked for. (A record above the core is a different level and is read before it, so a wording in a client entry is not shadowed this way — but it applies to that client alone.) The outcome receipts are where this is met first. Each of the three terminal kinds ships a wording at the bare `{kind}` **and** narrower ones for a sign-in: | Shipped address of `outcome-receipt-confirmed` | Text | | - | - | | `outcome-receipt-confirmed:Templates` | `Confirmed ✅` | | `…:ByType:login:BySurface:in-channel-messenger:Templates` | `Sign-in confirmed ✅` | | `…:ByType:login:BySurface:in-channel-messenger:ByDevice:mobile:Templates` and `…:ByDevice:tablet:Templates` | `Sign-in confirmed ✅ Tap Back to return to the sign-in page.` | | `…:ByType:login:BySurface:in-channel-plain:Templates` | `Sign-in confirmed` | | `…:ByType:login:BySurface:in-channel-plain:ByDevice:mobile:Templates` and `…:ByDevice:tablet:Templates` | `Sign-in confirmed. Tap Back to return to the sign-in page.` | | `…:ByType:login:BySurface:email-confirm-page:Templates` | `Sign-in confirmed! You can close this tab.` | `outcome-receipt-declined` ships the four addresses without `ByDevice`; `outcome-receipt-expired` ships those four plus a step at `ByType:login`. A receipt of a **sign-in** shown in a messenger asks about `{kind, login, in-channel-messenger}` — and about the channel, which no shipped receipt narrows by, and about the device the sign-in was started on — so restating `outcome-receipt-confirmed:Templates` changes nothing there. Word it at the same addresses the product used, the two device steps included: ```json appsettings.json theme={null} { "Veriqa": { "MessageTemplates": { "outcome-receipt-confirmed": { "Contract": { "Slots": [ { "Name": "app", "Type": "string", "Source": "ServerInitiatorContext" } ] }, "ByType": { "login": { "BySurface": { "in-channel-messenger": { "Templates": [ "Signed in to {app} ✅", "Signed in ✅" ], "ByDevice": { "mobile": { "Templates": [ "Signed in to {app} ✅ Tap Back to return.", "Signed in ✅ Tap Back to return." ] }, "tablet": { "Templates": [ "Signed in to {app} ✅ Tap Back to return.", "Signed in ✅ Tap Back to return." ] } } } } } } } } } } ``` **A sign-in started on a phone or a tablet gets a receipt of its own.** The button of the channel opens the app on the same device, so the receipt tells the user how to get back to the sign-in page; a sign-in started on a computer keeps the short text. The device steps are the reason the example above states `ByDevice`: in the configuration of the host a shipped step that names the device is asked before your step at the same address without it, so a declaration of `…:in-channel-messenger:Templates` alone leaves phones and tablets with the shipped `Sign-in confirmed ✅ Tap Back to return to the sign-in page.`. A declaration in a client entry, a tenant record or a `ui_config` record is a level above the core and answers whole — no shipped step is reached from there. The contract stays at the root of the kind — it narrows by the transaction type and the action type and never by the surface — and the ladder ends with a step using no slot, because no slot of a receipt is guaranteed (below). A receipt of a **confirmation** is a different address: the shipped file states nothing under `ByType:confirmation`, so there the broad `{kind}` declaration is the step that answers and rewording it works as written. The surfaces a receipt is addressed with are `in-channel-messenger` (a channel that renders emoji — the messengers the product ships an adapter for, and any channel declaring as much of itself), `in-channel-plain` (a channel that does not), `email-confirm-page` (the confirmation page of the Email channel), `core-page` (the confirmation page Veriqa serves itself) and `interaction-page` (the page a confirmation with no browser callback leaves the user on). **Outcome receipts are plain text only.** All four `outcome-receipt-*` kinds ship with one edition — `Plain` — and the general rule "an edition no step states is one the email does not carry" does not apply to them: a receipt has a single part, and not showing it means not answering the user at all. So a receipt ladder overridden with the `Html` edition **only** yields no text whatsoever — that is a broken installation rather than a different look: the render fails, naming the message kind, and no other edition is substituted for it. Escaping a receipt on its way into a page is the page's job, so plain text takes nothing away from your wording. **A receipt can name what was confirmed.** The shipped contract of the three receipt kinds declares no slots, so the shipped receipts say nothing about the action. Declare caller slots in the contract of a receipt on your level — under `ByType:confirmation:ByAction:{action type}` to word it per action — and the receipt is filled from the `slot_values` the confirmation was created with, by the same names. A caller slot of a receipt states `MaxLength`, as it does in the question: ```json theme={null} "outcome-receipt-confirmed": { "ByType": { "confirmation": { "ByAction": { "purchase-title": { "Contract": { "Slots": [ { "Name": "title", "Type": "string", "MaxLength": 64 } ] }, "Templates": [ "Purchase confirmed: “{title}” ✅", "Purchase confirmed ✅" ] } } } } } ``` A value the transaction does not carry (a sign-in, a confirmation created without it) leaves the step that uses it out, and the next step answers — so the minimal step of a receipt never counts a caller slot as guaranteed, even one marked `Required`. A receipt answering a decision from a channel the question was never shown in gets no caller values, and neither does a receipt on the entry page of a confirmation unless `InteractionPage` is in `ConfirmationSubjectDisplay.Surfaces`. The server slots described below are filled there all the same, except `outcome_at` on the entry page. **A receipt can also carry what the product knows about the transaction.** Its contract may declare the server slots `app`, `browser`, `os`, `region` (type `string`, `Source: ServerInitiatorContext`) and `outcome_at` (type `datetime`, `Source: ServerSystem`); a caller value never fills them. Naming one of those five `Source: Caller` instead is not refused at startup, but it does not hand you the slot: `app` is always the product's, and the other four are yours only on the branches where the product has no value of its own. Use a name of your own instead. `app` names the application: the `client_id` of a confirmation, the client's display name (or its `client_id`) for a sign-in. `browser`, `os` and `region` come from the initiator context, so only a sign-in has them, and they obey the same `Enabled` and `DisplayFields` of `Veriqa:InitiatorContext` as the sign-in prompt. `outcome_at` is the moment of the decision, or the deadline of an expired transaction; the entry page words its receipts before the outcome, so there it has no value. None of these slots is guaranteed in a receipt, even one marked `Guaranteed`, so the minimal step cannot rest on them. **The welcome message greets the user by name.** The kind `post-login-welcome` is the text sent when [`Veriqa:CompletionBehavior`](#veriqacompletionbehavior) resolves to `WelcomeMessage`. Its shipped contract declares one slot — `name` (type `string`, `Source: ServerIdentityClaims`), filled from the `name` claim of the identity the sign-in resolved — and its shipped ladder has two steps: ```json theme={null} "post-login-welcome": { "Contract": { "Slots": [ { "Name": "name", "Type": "string", "Source": "ServerIdentityClaims" } ] }, "Templates": [ "Welcome, {name}! You are signed in.", "Welcome! You are signed in." ] } ``` A channel that gave no name leaves the first step out, and the second answers. The message is sent as plain text and in the language of the recipient — the one the channel reported, otherwise the language of the page the sign-in started on. `name` is the only claim a slot is filled from as the product ships today: a slot of the same source under another name gets no value, so a step that uses it is always left out. Word the message per channel under `post-login-welcome:BySurface:in-channel:ByChannel:{channel}:Templates`. The levels of the two settings differ. The contract resolves from `Tenant`, `Application` and `Core`; the template from those **plus** a `ui_config` record. So a record selected by a request parameter can pick which of your wordings a user sees, yet can neither widen the slot list nor introduce an action type of its own. <Note> **The `Tenant` level.** No reader for the `Tenant` level is registered out of the box — the integrator writes one. Without it the resolution goes to `Application`/`Core`. </Note> **The `Application` level is the client entry.** Above the core the address is relative to the record of the level, and the record of `Application` is the entry of the client in `Veriqa:OpenIddict:Clients`: the contract of a message for one application sits at `Veriqa:OpenIddict:Clients:[n]:MessageTemplates:{kind}:…:Contract`. **Action types of confirmations your server creates** are message kinds of their own, and they are declared **only above the core** — in the client entry, as `MessageTemplates:{action type}` with a `Contract` and `Templates`. A kind visible only in the host section `Veriqa:MessageTemplates` is refused by `POST /api/transaction/confirmation` with `action_type_unknown`. A complete example with the request and the answer: [Server-to-server confirmation API](/docs/reference/confirmation-api). **The `device_type` axis refines any step.** Its value is the type of the device the transaction was started on, as the [initiator context](#veriqainitiatorcontext) states it — `desktop`, `mobile`, `tablet` or `unknown` — and its group, `ByDevice`, is the innermost one: it stands under whichever node the rest of the address names, the bare `{kind}` included, and needs no surface. A step that names the device is asked immediately before the same step without it, so a wording without `ByDevice` keeps answering for every device no wording was stated for. Only the receipts are asked about the device today; a transaction with no initiator context — it is switched off, or the device type is not collected — is asked about without it. The contract of a message does not narrow by the device. **The `channel` axis refines the surface**, so it never appears without `surface`. The product ships six values along it — both wordings of the pre-filled message, under `deeplink-prefill:BySurface:in-channel:ByChannel:whatsapp` and `…:ByChannel:email`, and the request for the phone number in Instagram and in Messenger, under `phone-number-request:BySurface:in-channel:ByChannel:instagram` and `…:ByChannel:messenger` and at the same two addresses of `phone-number-request-required`; the other kinds state no value along this axis, and a step without a channel answers for every channel at once. Asking a key about an axis it does not declare is an error. **Wording a message for one channel** therefore takes the surface as well. The request for the phone number is the plain case: `phone-number-request` ships `Share your phone number` at the bare `{kind}` and a wording for each of two channels — Instagram and Messenger, which adds what to do without a number in the profile ([phone number](/docs/guides/phone-number#instagram)) — and it is asked about with the surface `in-channel` and the channel of the sign-in. The request for a `Required` number is the kind `phone-number-request-required`, shipped and addressed the same way. A wording for one channel sits at `Veriqa:MessageTemplates:phone-number-request:BySurface:in-channel:ByChannel:{channel}:Templates`: ```json appsettings.json theme={null} { "Veriqa": { "MessageTemplates": { "phone-number-request": { "BySurface": { "in-channel": { "ByChannel": { "telegram": { "Templates": [ "Tap the button below to share your phone number" ] } } } } } } } } ``` The other channels keep their shipped text. The same address in a client entry words the request for one application. The shipped contract of the kind declares no slots, so a wording that uses one needs a contract of yours at `phone-number-request:Contract`; the request fills the server slots `app`, `browser`, `os` and `region` from the transaction, by the rules described for the receipts above, and never a caller slot. End the ladder with a step that uses no slot: `browser`, `os` and `region` may have no value, a caller slot of this kind never has one, and marking a slot `Guaranteed` or `Required` does not change that. The text is rendered as plain text and translated into the user's language like any other wording. ## Health endpoints | Endpoint | Purpose | | - | - | | `/health/live` | Liveness — the process is up | | `/health/ready` | Readiness — configured dependencies answer | Readiness registers a check per configured dependency, so it reflects exactly what you wired up: no database configured means no database check. The remaining endpoints, the metrics and the traces: [Observability](/docs/guides/observability). ## Keys in the declaration catalog The table below is built from the **declaration catalog** of keys: the name, the value type, the levels, the dimensions and the address on every level. The remaining keys are described in the sections above. The keys below are the ones **Veriqa** owns. The same catalog is open to what you ship: an extension of your own declares its keys on the core, application and `ui_config` levels through the same public API, and its declarations join this schema at startup — see [Declare your own configuration keys](/docs/guides/config-keys). The "Default" column prints what the owner of the key declared: what the core level answers when its record states nothing. For a key that declares no such answer, the column says so outright — its defaults live in the sections above. "Declared, value not printed" stands where an answer is declared and has no text to it: the declared answer is `null`, or the key is declared secret — the schema of a secret key carries the fact of the answer and never its text. What an installation put into a setting is in no column of this table: what is printed here is a declaration, not a resolution. | Key | Value type | Levels | Dimensions | Address per level | Default | Secret | | - | - | - | - | - | - | - | | `AuthPage.Instruction` | `string` | UiConfig | — | UiConfig: `instruction` | not declared | no | | `AuthPage.Texts` | `CorePageTextOverride` | UiConfig · Application · Tenant | `text` | UiConfig: `texts:{text}`<br />Application: `AuthPage:Texts:{text}`<br />Tenant: `AuthPage:Texts:{text}` | not declared | no | | `AuthPage.Title` | `string` | UiConfig | — | UiConfig: `title` | not declared | no | | `AuthPageDesign.BrandName` | `string` | UiConfig · Application · Tenant · Core | — | UiConfig: `brand_name`<br />Application: `BrandName`<br />Tenant: `BrandName`<br />Core: `Veriqa:AuthPageDesign:BrandName` | not declared | no | | `AuthPageDesign.Cancel.Enabled` | `bool` | UiConfig · Application · Tenant · Core | — | UiConfig: `cancel:enabled`<br />Application: `Cancel:Enabled`<br />Tenant: `Cancel:Enabled`<br />Core: `Veriqa:AuthPageDesign:Cancel:Enabled` | `true` | no | | `AuthPageDesign.ChannelButton.ShowChannelButton` | `ChannelButtonVisibility` | UiConfig · Application · Tenant · Core | — | UiConfig: `channel_button:show_channel_button`<br />Application: `ChannelButton:ShowChannelButton`<br />Tenant: `ChannelButton:ShowChannelButton`<br />Core: `Veriqa:AuthPageDesign:ChannelButton:ShowChannelButton` | `Always` | no | | `AuthPageDesign.ChannelLayout` | `ChannelLayout` | UiConfig · Application · Tenant · Core | — | UiConfig: `channel_layout`<br />Application: `ChannelLayout`<br />Tenant: `ChannelLayout`<br />Core: `Veriqa:AuthPageDesign:ChannelLayout` | `Tabs` | no | | `AuthPageDesign.CustomCss` | `CorePageCustomResource` | UiConfig · Application · Core | — | UiConfig: `custom_css_path`<br />Application: `CustomCssPath`<br />Core: `Veriqa:AuthPageDesign:CustomCssPath` | not declared | no | | `AuthPageDesign.CustomJs` | `CorePageCustomResource` | Application (behind a gate) · Core | — | Application: `CustomJsPath`<br />Core: `Veriqa:AuthPageDesign:CustomJsPath` | not declared | no | | `AuthPageDesign.FooterHtml` | `string` | Application · Tenant · Core | — | Application: `FooterHtml`<br />Tenant: `FooterHtml`<br />Core: `Veriqa:AuthPageDesign:FooterHtml` | not declared | no | | `AuthPageDesign.HopInstructionHtml` | `string` | Application · Tenant · Core | — | Application: `HopInstructionHtml`<br />Tenant: `HopInstructionHtml`<br />Core: `Veriqa:AuthPageDesign:HopInstructionHtml` | not declared | no | | `AuthPageDesign.LogoUrl` | `string` | UiConfig · Application · Tenant · Core | `theme` | UiConfig: `logo_url`<br />Application: `LogoUrl`<br />Tenant: `LogoUrl`<br />Core: `Veriqa:AuthPageDesign:LogoUrl` | not declared | no | | `AuthPageDesign.Preset` | `DesignPreset` | UiConfig · Core | — | UiConfig: `preset`<br />Core: `Veriqa:AuthPageDesign:Preset` | `Default` | no | | `AuthPageDesign.PrimaryColor` | `string` | UiConfig · Application · Core | `theme` | UiConfig: `primary_color`<br />Application: `PrimaryColor`<br />Core: `Veriqa:AuthPageDesign:PrimaryColor` | not declared | no | | `AuthPageDesign.QrCode.PixelsPerModule` | `int` | UiConfig · Application · Tenant · Core | `channel` | UiConfig: `qr_code:pixels_per_module`<br />Application: `QrCode:PixelsPerModule`<br />Tenant: `QrCode:PixelsPerModule`<br />Core: `Veriqa:AuthPageDesign:QrCode:PixelsPerModule` | not declared | no | | `AuthPageDesign.QrCode.ShowQrCode` | `QrCodeVisibility` | UiConfig · Application · Tenant · Core | — | UiConfig: `qr_code:show_qr_code`<br />Application: `QrCode:ShowQrCode`<br />Tenant: `QrCode:ShowQrCode`<br />Core: `Veriqa:AuthPageDesign:QrCode:ShowQrCode` | `DesktopOnly` | no | | `AuthPageDesign.SignalRClientPath` | `string` | UiConfig · Core | — | UiConfig: `signalr_client_path`<br />Core: `Veriqa:AuthPageDesign:SignalRClientPath` | not declared | no | | `AuthPageDesign.Theme` | `AuthPageTheme` | UiConfig · Core | — | UiConfig: `theme`<br />Core: `Veriqa:AuthPageDesign:Theme` | `Auto` | no | | `ChannelDisplay.Channels` | `IReadOnlyList`1\` | UiConfig | — | UiConfig: `channels` | not declared | no | | `ChannelDisplay.Hints` | `string` | Core | `channel` | Core: `Veriqa:ChannelDisplay:Hints:{channel}` | not declared | no | | `ChannelDisplay.Mode` | `ChannelDisplayMode` | UiConfig · Core | — | UiConfig: `channel_display_mode`<br />Core: `Veriqa:ChannelDisplay:Mode` | `Multichannel` | no | | `Channels.BotName` | `string` | Tenant · Core | `channel` | Tenant: no path<br />Core: `Veriqa:Channels:{channel}:BotName` | not declared | no | | `Channels.Enabled` | `IReadOnlyCollection`1\` | Tenant · Core | — | Tenant: no path<br />Core: no path | not declared | no | | `Channels.HealthProbeInterval` | `TimeSpan` | Core | `channel` | Core: `Veriqa:Channels:{channel}:HealthProbeInterval` | `00:00:15` | no | | `Channels.Http.AdapterSettings` | `string` | Tenant · Core | `channel` | Tenant: no path<br />Core: `Veriqa:Channels:{channel}:Http:AdapterSettings` | not declared | yes | | `Channels.Http.AllowInsecureHttp` | `bool` | Core | `channel` | Core: `Veriqa:Channels:{channel}:Http:AllowInsecureHttp` | `false` | no | | `Channels.Http.BaseUrl` | `string` | Core | `channel` | Core: `Veriqa:Channels:{channel}:Http:BaseUrl` | not declared | no | | `Channels.Http.Capabilities.CodeStart.LinkCarriesTransaction` | `bool?` | Core | `channel` | Core: `Veriqa:Channels:{channel}:Http:Capabilities:CodeStart:LinkCarriesTransaction` | not declared | no | | `Channels.Http.Capabilities.CodeStart.MessageTemplate` | `string` | Core | `channel` | Core: `Veriqa:Channels:{channel}:Http:Capabilities:CodeStart:MessageTemplate` | not declared | no | | `Channels.Http.Capabilities.DeliversOutcomeNotice` | `bool` | Core | `channel` | Core: `Veriqa:Channels:{channel}:Http:Capabilities:DeliversOutcomeNotice` | `false` | no | | `Channels.Http.Capabilities.PhoneNumber.Acquisition` | `IReadOnlyList`1\` | Core | `channel` | Core: `Veriqa:Channels:{channel}:Http:Capabilities:PhoneNumber:Acquisition` | `[]` | no | | `Channels.Http.Capabilities.PhoneNumber.Proof` | `PhoneNumberProof` | Core | `channel` | Core: `Veriqa:Channels:{channel}:Http:Capabilities:PhoneNumber:Proof` | `NotProven` | no | | `Channels.Http.Capabilities.ProvidesRecipientLocale` | `bool` | Core | `channel` | Core: `Veriqa:Channels:{channel}:Http:Capabilities:ProvidesRecipientLocale` | `false` | no | | `Channels.Http.Capabilities.RendersEmoji` | `bool?` | Core | `channel` | Core: `Veriqa:Channels:{channel}:Http:Capabilities:RendersEmoji` | not declared | no | | `Channels.Http.Capabilities.SupportedMessageKinds` | `IReadOnlyList`1\` | Core | `channel` | Core: `Veriqa:Channels:{channel}:Http:Capabilities:SupportedMessageKinds` | `[plain_text]` | no | | `Channels.Http.Capabilities.SupportsInChannelConfirmation` | `bool` | Core | `channel` | Core: `Veriqa:Channels:{channel}:Http:Capabilities:SupportsInChannelConfirmation` | `false` | no | | `Channels.Http.Capabilities.SupportsMessageUpdate` | `bool` | Core | `channel` | Core: `Veriqa:Channels:{channel}:Http:Capabilities:SupportsMessageUpdate` | `false` | no | | `Channels.Http.DeepLinkSchemes` | `IReadOnlyList`1\` | Core | `channel` | Core: `Veriqa:Channels:{channel}:Http:DeepLinkSchemes` | `[]` | no | | `Channels.Http.DeepLinkTimeout` | `TimeSpan` | Core | `channel` | Core: `Veriqa:Channels:{channel}:Http:DeepLinkTimeout` | `00:00:02` | no | | `Channels.Http.DisplayName` | `string` | Core | `channel` | Core: `Veriqa:Channels:{channel}:Http:DisplayName` | not declared | no | | `Channels.Http.ErrorMessageText` | `string` | Core | `channel` | Core: `Veriqa:Channels:{channel}:Http:ErrorMessageText` | `Something went wrong. Go back to the sign-in page and start again.` | no | | `Channels.Http.IconSvgPath` | `string` | Core | `channel` | Core: `Veriqa:Channels:{channel}:Http:IconSvgPath` | not declared | no | | `Channels.Http.OperationTimeout` | `TimeSpan` | Core | `channel` | Core: `Veriqa:Channels:{channel}:Http:OperationTimeout` | `00:00:10` | no | | `Channels.Http.PreviousTransportSecret` | `string` | Core | `channel` | Core: `Veriqa:Channels:{channel}:Http:PreviousTransportSecret` | not declared | yes | | `Channels.Http.RetryBudget` | `TimeSpan` | Core | `channel` | Core: `Veriqa:Channels:{channel}:Http:RetryBudget` | `00:00:20` | no | | `Channels.Http.StaleLinkReplyText` | `string` | Core | `channel` | Core: `Veriqa:Channels:{channel}:Http:StaleLinkReplyText` | not declared | no | | `Channels.Http.Tenant` | `string` | Core | `channel` | Core: `Veriqa:Channels:{channel}:Http:Tenant` | not declared | no | | `Channels.Http.TransportSecret` | `string` | Core | `channel` | Core: `Veriqa:Channels:{channel}:Http:TransportSecret` | not declared | yes | | `Channels.Http.UnaddressedReplyText` | `string` | Core | `channel` | Core: `Veriqa:Channels:{channel}:Http:UnaddressedReplyText` | not declared | no | | `Channels.Http.UnmatchedCodeReplyText` | `string` | Core | `channel` | Core: `Veriqa:Channels:{channel}:Http:UnmatchedCodeReplyText` | not declared | no | | `Channels.InboundVerification` | `ChannelInboundVerification?` | Tenant · Core | `channel` | Tenant: no path<br />Core: `Veriqa:Channels:{channel}:InboundVerification` | not declared | no | | `Channels.OutcomeNotice.DisplayIntent` | `OutcomeNoticeDisplayIntent` | Application · Tenant · Core | — | Application: `OutcomeNoticeDisplayIntent`<br />Tenant: `OutcomeNoticeDisplayIntent`<br />Core: `Veriqa:Channels:OutcomeNotice:DisplayIntent` | `ReplacePrompt` | no | | `Channels.SpareStartCodeEnabled` | `bool` | Tenant · Core | `channel` | Tenant: no path<br />Core: `Veriqa:Channels:{channel}:SpareStartCodeEnabled` | `true` | no | | `Channels.email.Credentials` | `EmailTenantCredentials` | Tenant · Core | — | Tenant: no path<br />Core: no path | not declared | yes | | `Channels.instagram.Credentials` | `InstagramTenantCredentials` | Tenant · Core | — | Tenant: no path<br />Core: no path | not declared | yes | | `Channels.instagram.UnaddressedReplyEnabled` | `bool` | Tenant · Core | — | Tenant: no path<br />Core: `Veriqa:Channels:Instagram:UnaddressedReplyEnabled` | `true` | no | | `Channels.max.Credentials` | `MaxTenantCredentials` | Tenant · Core | — | Tenant: no path<br />Core: no path | not declared | yes | | `Channels.messenger.Credentials` | `MessengerTenantCredentials` | Tenant · Core | — | Tenant: no path<br />Core: no path | not declared | yes | | `Channels.messenger.DeepLinkByPageId` | `bool` | Tenant · Core | — | Tenant: no path<br />Core: `Veriqa:Channels:Messenger:DeepLinkByPageId` | `false` | no | | `Channels.messenger.UnaddressedReplyEnabled` | `bool` | Tenant · Core | — | Tenant: no path<br />Core: `Veriqa:Channels:Messenger:UnaddressedReplyEnabled` | `true` | no | | `Channels.telegram.Credentials` | `TelegramTenantCredentials` | Tenant · Core | — | Tenant: no path<br />Core: no path | not declared | yes | | `Channels.viber.Credentials` | `ViberTenantCredentials` | Tenant · Core | — | Tenant: no path<br />Core: no path | not declared | yes | | `Channels.wechat.Credentials` | `WeChatTenantCredentials` | Tenant · Core | — | Tenant: no path<br />Core: no path | not declared | yes | | `Channels.whatsapp.Credentials` | `WhatsAppTenantCredentials` | Tenant · Core | — | Tenant: no path<br />Core: no path | not declared | yes | | `ClaimCompletion.CollectedClaimsRetentionDays` | `int` | Tenant · Core | — | Tenant: `ClaimCompletionCollectedClaimsRetentionDays`<br />Core: `Veriqa:ClaimCompletion:CollectedClaims:RetentionDays` | `0` | no | | `ClaimCompletion.EmailCodeAttempts` | `int` | Application · Tenant · Core | — | Application: `ClaimCompletionEmailLimits:CodeAttempts`<br />Tenant: `ClaimCompletionEmailLimits:CodeAttempts`<br />Core: `Veriqa:ClaimCompletion:EmailLimits:CodeAttempts` | `5` | no | | `ClaimCompletion.EmailMailsPerAddress` | `int` | Application · Tenant · Core | — | Application: `ClaimCompletionEmailLimits:MailsPerAddress`<br />Tenant: `ClaimCompletionEmailLimits:MailsPerAddress`<br />Core: `Veriqa:ClaimCompletion:EmailLimits:MailsPerAddress` | `5` | no | | `ClaimCompletion.EmailMailsPerDomain` | `int` | Application · Tenant · Core | — | Application: `ClaimCompletionEmailLimits:MailsPerDomain`<br />Tenant: `ClaimCompletionEmailLimits:MailsPerDomain`<br />Core: `Veriqa:ClaimCompletion:EmailLimits:MailsPerDomain` | `0` | no | | `ClaimCompletion.EmailMailsPerSubject` | `int` | Application · Tenant · Core | — | Application: `ClaimCompletionEmailLimits:MailsPerSubject`<br />Tenant: `ClaimCompletionEmailLimits:MailsPerSubject`<br />Core: `Veriqa:ClaimCompletion:EmailLimits:MailsPerSubject` | `10` | no | | `ClaimCompletion.EmailMailsPerTransaction` | `int` | Application · Tenant · Core | — | Application: `ClaimCompletionEmailLimits:MailsPerTransaction`<br />Tenant: `ClaimCompletionEmailLimits:MailsPerTransaction`<br />Core: `Veriqa:ClaimCompletion:EmailLimits:MailsPerTransaction` | `3` | no | | `ClaimCompletion.EmailMethods` | `IReadOnlyList`1\` | Application · Tenant · Core | — | Application: `ClaimCompletionEmailMethods`<br />Tenant: `ClaimCompletionEmailMethods`<br />Core: `Veriqa:ClaimCompletion:EmailMethods` | `[Push, Code, MagicLink]` | no | | `ClaimCompletion.FormEnabled` | `bool` | Application · Core | — | Application: `ClaimCompletionFormEnabled`<br />Core: `Veriqa:ClaimCompletion:FormEnabled` | `true` | no | | `ClaimCompletion.FormSurface` | `ClaimFormSurface` | Application · Core | — | Application: `ClaimCompletionFormSurface`<br />Core: `Veriqa:ClaimCompletion:FormSurface` | `Auto` | no | | `ClaimCompletion.PhoneOptionalWindow` | `TimeSpan` | Application · Core | — | Application: `ClaimCompletionPhoneOptionalWindow`<br />Core: `Veriqa:ClaimCompletion:PhoneOptionalWindow` | `00:01:00` | no | | `Client.InitiateLoginUri` | `string` | Application | — | Application: `InitiateLoginUri` | not declared | no | | `CompletionBehavior.Action` | `CompletionBehaviorAction` | Application · Tenant · Core | `channel` | Application: `CompletionBehavior:Action`<br />Tenant: `CompletionBehavior:Action`<br />Core: `Veriqa:CompletionBehavior:Action` | not declared | no | | `CompletionBehavior.MenuItems` | `IReadOnlyList`1\` | Application · Tenant · Core | `channel` | Application: `CompletionBehavior:MenuItems`<br />Tenant: `CompletionBehavior:MenuItems`<br />Core: `Veriqa:CompletionBehavior:MenuItems` | not declared | no | | `CompletionBehavior.RedirectButtonText` | `string` | Application · Tenant · Core | — | Application: `CompletionBehavior:RedirectButtonText`<br />Tenant: `CompletionBehavior:RedirectButtonText`<br />Core: `Veriqa:CompletionBehavior:RedirectButtonText` | `Open` | no | | `CompletionBehavior.RedirectUrl` | `string` | Application · Tenant · Core | `channel` | Application: `CompletionBehavior:RedirectUrl`<br />Tenant: `CompletionBehavior:RedirectUrl`<br />Core: `Veriqa:CompletionBehavior:RedirectUrl` | not declared | no | | `ConfirmationSubjectDisplay.Surfaces` | `IReadOnlyCollection`1\` | Application · Tenant · Core | — | Application: `ConfirmationSubjectDisplaySurfaces`<br />Tenant: `ConfirmationSubjectDisplaySurfaces`<br />Core: `Veriqa:ConfirmationSubjectDisplay:Surfaces` | `[]` | no | | `HealthChecks.ChannelProbeLimit` | `TimeSpan` | Core | — | Core: `Veriqa:HealthChecks:ChannelProbeLimit` | `00:01:00` | no | | `HopMode.AcceptClientSignals` | `bool` | Tenant · Core | — | Tenant: `HopMode:AcceptClientSignals`<br />Core: `Veriqa:HopMode:AcceptClientSignals` | `true` | no | | `HopMode.CollectFingerprint` | `bool` | Tenant · Core | — | Tenant: `HopMode:CollectFingerprint`<br />Core: `Veriqa:HopMode:CollectFingerprint` | `false` | no | | `HopMode.CollectGeoLocation` | `bool` | Tenant · Core | — | Tenant: `HopMode:CollectGeoLocation`<br />Core: `Veriqa:HopMode:CollectGeoLocation` | `false` | no | | `HopMode.CollectIpAddress` | `bool` | Tenant · Core | — | Tenant: `HopMode:CollectIpAddress`<br />Core: `Veriqa:HopMode:CollectIpAddress` | `true` | no | | `HopMode.CollectScannerContext` | `bool` | Tenant · Core | — | Tenant: `HopMode:CollectScannerContext`<br />Core: `Veriqa:HopMode:CollectScannerContext` | `true` | no | | `HopMode.CollectUserAgent` | `bool` | Tenant · Core | — | Tenant: `HopMode:CollectUserAgent`<br />Core: `Veriqa:HopMode:CollectUserAgent` | `true` | no | | `HopMode.EmitScannedStatus` | `bool` | Tenant · Core | — | Tenant: `HopMode:EmitScannedStatus`<br />Core: `Veriqa:HopMode:EmitScannedStatus` | `true` | no | | `HopMode.Enabled` | `bool` | Application · Tenant · Core | `channel` | Application: `HopMode:Enabled`<br />Tenant: `HopMode:Enabled`<br />Core: `Veriqa:HopMode:Enabled` | not declared | no | | `HopMode.HopPath` | `string` | Core | — | Core: `Veriqa:HopMode:HopPath` | `/r` | no | | `HopMode.QrMode` | `HopQrMode` | Application · Tenant · Core | — | Application: `HopMode:QrMode`<br />Tenant: `HopMode:QrMode`<br />Core: `Veriqa:HopMode:QrMode` | `PerChannel` | no | | `HopMode.RedirectStyle` | `HopRedirectStyle` | Application · Tenant · Core | `channel` | Application: `HopMode:RedirectStyle`<br />Tenant: `HopMode:RedirectStyle`<br />Core: `Veriqa:HopMode:RedirectStyle` | not declared | no | | `HopMode.ShowChannelButtonsOnAnyChannelTab` | `bool` | Application · Tenant · Core | — | Application: `HopMode:ShowChannelButtonsOnAnyChannelTab`<br />Tenant: `HopMode:ShowChannelButtonsOnAnyChannelTab`<br />Core: `Veriqa:HopMode:ShowChannelButtonsOnAnyChannelTab` | `true` | no | | `HopMode.StoreRawUserAgent` | `bool` | Tenant · Core | — | Tenant: `HopMode:StoreRawUserAgent`<br />Core: `Veriqa:HopMode:StoreRawUserAgent` | `false` | no | | `HopMode.TokenLifetime` | `TimeSpan?` | Application · Tenant · Core | — | Application: no path<br />Tenant: `HopMode:TokenLifetime`<br />Core: `Veriqa:HopMode:TokenLifetime` | declared, value not printed | no | | `HopMode.WarnOnRepeatScan` | `bool` | Tenant · Core | — | Tenant: `HopMode:WarnOnRepeatScan`<br />Core: `Veriqa:HopMode:WarnOnRepeatScan` | `true` | no | | `IdentityMatch.ComparableTypes` | `IReadOnlyList`1\` | Application · Tenant · Core | — | Application: `IdentityMatchComparableTypes`<br />Tenant: `IdentityMatchComparableTypes`<br />Core: `Veriqa:IdentityMatch:ComparableTypes` | `[]` | no | | `InitiatorContext.DisplayFields` | `IReadOnlyList`1\` | Application · Core | — | Application: `InitiatorContextDisplayFields`<br />Core: `Veriqa:InitiatorContext:DisplayFields` | `[application, browser, os, region]` | no | | `InitiatorContext.Enabled` | `bool` | Application · Core | — | Application: `InitiatorContextEnabled`<br />Core: `Veriqa:InitiatorContext:Enabled` | `true` | no | | `Logging.Mode` | `LoggingMode` | Tenant · Core | — | Tenant: `Logging:Mode` (derived from the name)<br />Core: `Veriqa:Logging:Mode` | `System` | no | | `Logging.RetentionDays` | `int` | Core | — | Core: `Veriqa:Logging:RetentionDays` | `90` | no | | `LoginConfirmation.Mode` | `LoginConfirmationMode` | Application · Tenant · Core | — | Application: `LoginConfirmationMode`<br />Tenant: `LoginConfirmationMode`<br />Core: `Veriqa:LoginConfirmation:Mode` | `ChannelDefault` | no | | `MessageTemplates.Contract` | `MessageContract` | Application · Tenant · Core | `kind` · `transaction_type` · `action_type` | Application: `MessageTemplates:{kind}:[ByType:{transaction_type}]:[ByAction:{action_type}]:Contract`<br />Tenant: `MessageTemplates:{kind}:[ByType:{transaction_type}]:[ByAction:{action_type}]:Contract`<br />Core: `Veriqa:MessageTemplates:{kind}:[ByType:{transaction_type}]:[ByAction:{action_type}]:Contract` | not declared | no | | `MessageTemplates.Template` | `MessageTemplateVariant[]` | UiConfig · Application · Tenant · Core | `kind` · `transaction_type` · `action_type` · `surface` · `channel` · `device_type` | UiConfig: `MessageTemplates:{kind}:[ByType:{transaction_type}]:[ByAction:{action_type}]:[BySurface:{surface}]:[ByChannel:{channel}]:[ByDevice:{device_type}]:Templates`<br />Application: `MessageTemplates:{kind}:[ByType:{transaction_type}]:[ByAction:{action_type}]:[BySurface:{surface}]:[ByChannel:{channel}]:[ByDevice:{device_type}]:Templates`<br />Tenant: `MessageTemplates:{kind}:[ByType:{transaction_type}]:[ByAction:{action_type}]:[BySurface:{surface}]:[ByChannel:{channel}]:[ByDevice:{device_type}]:Templates`<br />Core: `Veriqa:MessageTemplates:{kind}:[ByType:{transaction_type}]:[ByAction:{action_type}]:[BySurface:{surface}]:[ByChannel:{channel}]:[ByDevice:{device_type}]:Templates` | not declared | no | | `OidcServer.AccessTokenLifetimeSeconds` | `int` | Core | — | Core: `Veriqa:OpenIddict:Server:AccessTokenLifetimeSeconds` | `3600` | no | | `OidcServer.AuthorizationCodeLifetimeSeconds` | `int` | Core | — | Core: `Veriqa:OpenIddict:Server:AuthorizationCodeLifetimeSeconds` | `300` | no | | `OidcServer.EnableRefreshTokenRotation` | `bool` | Core | — | Core: `Veriqa:OpenIddict:Server:EnableRefreshTokenRotation` | `true` | no | | `OidcServer.EnableRevocation` | `bool` | Core | — | Core: `Veriqa:OpenIddict:Server:EnableRevocation` | `true` | no | | `OidcServer.Issuer` | `string` | Core | — | Core: `Veriqa:OpenIddict:Server:Issuer` | declared, value not printed | no | | `OidcServer.RefreshTokenLifetimeSeconds` | `int` | Core | — | Core: `Veriqa:OpenIddict:Server:RefreshTokenLifetimeSeconds` | `1209600` | no | | `OpenIddict.ClientCompatibilityQuirks` | `IReadOnlyList`1\` | Application · Core | — | Application: `CompatibilityQuirks`<br />Core: `Veriqa:OpenIddict:CompatibilityQuirksAllowApplicationOverride` | not declared | no | | `RateLimit.AuthorizePermitLimit` | `int` | Core | — | Core: `Veriqa:RateLimit:AuthorizePermitLimit` | `60` | no | | `RateLimit.AuthorizeWindowSeconds` | `int` | Core | — | Core: `Veriqa:RateLimit:AuthorizeWindowSeconds` | `60` | no | | `RateLimit.CallbackPermitLimit` | `int` | Core | — | Core: `Veriqa:RateLimit:CallbackPermitLimit` | `60` | no | | `RateLimit.CallbackWindowSeconds` | `int` | Core | — | Core: `Veriqa:RateLimit:CallbackWindowSeconds` | `60` | no | | `RateLimit.ConfirmationCreatePermitLimit` | `int` | Core | — | Core: `Veriqa:RateLimit:ConfirmationCreatePermitLimit` | `60` | no | | `RateLimit.ConfirmationCreateWindowSeconds` | `int` | Core | — | Core: `Veriqa:RateLimit:ConfirmationCreateWindowSeconds` | `60` | no | | `RateLimit.ConfirmationPagesPermitLimit` | `int` | Core | — | Core: `Veriqa:RateLimit:ConfirmationPagesPermitLimit` | `30` | no | | `RateLimit.ConfirmationPagesWindowSeconds` | `int` | Core | — | Core: `Veriqa:RateLimit:ConfirmationPagesWindowSeconds` | `60` | no | | `RateLimit.ConfirmationResultPermitLimit` | `int` | Core | — | Core: `Veriqa:RateLimit:ConfirmationResultPermitLimit` | `60` | no | | `RateLimit.ConfirmationResultWindowSeconds` | `int` | Core | — | Core: `Veriqa:RateLimit:ConfirmationResultWindowSeconds` | `60` | no | | `RateLimit.EmailStartPermitLimit` | `int` | Core | — | Core: `Veriqa:RateLimit:EmailStartPermitLimit` | `5` | no | | `RateLimit.EmailStartWindowSeconds` | `int` | Core | — | Core: `Veriqa:RateLimit:EmailStartWindowSeconds` | `60` | no | | `RateLimit.HopResolvePermitLimit` | `int` | Core | — | Core: `Veriqa:RateLimit:HopResolvePermitLimit` | `30` | no | | `RateLimit.HopResolveWindowSeconds` | `int` | Core | — | Core: `Veriqa:RateLimit:HopResolveWindowSeconds` | `60` | no | | `RateLimit.HopSignalPermitLimit` | `int` | Core | — | Core: `Veriqa:RateLimit:HopSignalPermitLimit` | `60` | no | | `RateLimit.HopSignalWindowSeconds` | `int` | Core | — | Core: `Veriqa:RateLimit:HopSignalWindowSeconds` | `60` | no | | `RateLimit.PollingPermitLimit` | `int` | Core | — | Core: `Veriqa:RateLimit:PollingPermitLimit` | `600` | no | | `RateLimit.PollingWindowSeconds` | `int` | Core | — | Core: `Veriqa:RateLimit:PollingWindowSeconds` | `60` | no | | `RateLimit.SignalRPermitLimit` | `int` | Core | — | Core: `Veriqa:RateLimit:SignalRPermitLimit` | `30` | no | | `RateLimit.SignalRWindowSeconds` | `int` | Core | — | Core: `Veriqa:RateLimit:SignalRWindowSeconds` | `60` | no | | `RateLimit.TokenPermitLimit` | `int` | Core | — | Core: `Veriqa:RateLimit:TokenPermitLimit` | `20` | no | | `RateLimit.TokenWindowSeconds` | `int` | Core | — | Core: `Veriqa:RateLimit:TokenWindowSeconds` | `60` | no | | `RateLimit.UserAuthPermitLimit` | `int` | Core | — | Core: `Veriqa:RateLimit:UserAuthPermitLimit` | `5` | no | | `RateLimit.UserAuthWindowSeconds` | `int` | Core | — | Core: `Veriqa:RateLimit:UserAuthWindowSeconds` | `60` | no | | `RateLimit.WebhookPermitLimit` | `int` | Core | — | Core: `Veriqa:RateLimit:WebhookPermitLimit` | `100` | no | | `RateLimit.WebhookWindowSeconds` | `int` | Core | — | Core: `Veriqa:RateLimit:WebhookWindowSeconds` | `60` | no | | `RestrictionPolicy.Channel` | `RestrictionPolicySet` | Application · Tenant · Core | `channel` | Application: `RestrictionPolicyByChannel:{channel}`<br />Tenant: `RestrictionPolicyByChannel:{channel}`<br />Core: `Veriqa:RestrictionPolicies:ByChannel:{channel}` | not declared | no | | `RestrictionPolicy.Common` | `RestrictionPolicySet` | Application · Tenant · Core | — | Application: `RestrictionPolicy`<br />Tenant: `RestrictionPolicy`<br />Core: `Veriqa:RestrictionPolicies:Common` | not declared | no | | `ScopesClaims.Catalog` | `IReadOnlyList`1\` | Tenant · Core | — | Tenant: no path<br />Core: no path | not declared | no | | `ScopesClaims.ClaimLevel` | `ClaimRequirementLevel` | Application | `claim` | Application: `ClaimLevels:{claim}` | not declared | no | | `ScopesClaims.RequireEmail` | `bool` | Application · Tenant · Core | — | Application: `RequireEmail`<br />Tenant: `RequireEmail`<br />Core: `Veriqa:ScopesClaims:RequireEmail` | `false` | no | | `ScopesClaims.RequirePhone` | `bool` | Application · Tenant · Core | — | Application: `RequirePhone`<br />Tenant: `RequirePhone`<br />Core: `Veriqa:ScopesClaims:RequirePhone` | `false` | no | | `Transaction.DefaultTimeZone` | `string` | UiConfig | — | UiConfig: `default_time_zone` | not declared | no | | `Transaction.TtlSeconds` | `int` | UiConfig | — | UiConfig: `ttl_seconds` | not declared | no | | `TransactionEngine.RejectBots` | `bool` | Application · Core | — | Application: `RejectBots`<br />Core: `Veriqa:TransactionEngine:RejectBots` | not declared | no | | `UiConfig.SelectorAssigned` | `bool` | UiConfig | — | UiConfig: `schema_version` | not declared | no | ## Next steps <CardGroup> <Card title="Channel setup" icon="comments" href="/docs/guides/channels"> Telegram, WhatsApp, other channels and Email — end to end. </Card> <Card title="Production hardening" icon="shield-check" href="/docs/guides/hardening"> What to check before shipping. </Card> </CardGroup> # Server-to-server confirmation API Source: https://veriqa.app/docs/reference/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 responses. 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 response — 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 prevents startup | | `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 control the wording at 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": "ServerInitiatorContext" } ] }, "Templates": [ "{app}: confirmed ✅", "Confirmed ✅" ] }, "outcome-receipt-declined": { "Contract": { "Slots": [ { "Name": "app", "Type": "string", "Source": "ServerInitiatorContext" } ] }, "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 honor 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` | If omitted, the source is `Caller`: your backend fills it in `slot_values`. `ServerInitiatorContext`: the server fills it from the transaction context — `app` (the client name), and for a sign-in also `browser`, `os` and `region`; the length is capped, as for any value from outside. `ServerSystem`: a value the core computes itself (`link`, `valid_until`, `code`, `lang`); the length is not capped, and a moment is shown as a time of day | | `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 discarded as a 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 response 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 responds with `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 returns 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. If absent, the base language is used | | `time_zone` | string, IANA | Time zone for the dates and times in the wording. If absent, the deployment default is used, or else UTC | | `ttl_seconds` | integer | Lifetime of the transaction, clamped to 60…1800. If absent, `Veriqa:TransactionEngine:TransactionTtlSeconds` applies | | `ui_config` | string | A `ui_config` record allowed to this client. If absent, the client's default is used | | `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 response — `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 response 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 response 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` set to `Unified` or `UnifiedWithChannels`, 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. `null` when `url` is too long for a QR code — show the link alone | | `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 works like a bearer token.** 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 recognize 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 the person's decision, 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 — returns 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 a Problem Details response (`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 channel entry could be built right now — for example, no channel is enabled. A repeat with the same `idempotency_key` returns it again | ## Next steps <CardGroup> <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> # Known limitations Source: https://veriqa.app/docs/reference/limitations What Veriqa does not have today, what behaves differently from what you might expect, and what compatibility is verified. These limitations are described on their own pages as well — here they are gathered in one place so that the "does this fit" decision happens before an integration rather than halfway through it. ## Not in the product | Limitation | Details | | - | - | | **The cloud is unavailable** | The cloud documentation describes a fixed API contract, not a running service — [Veriqa Cloud](/docs/cloud/overview). The self-hosted path works | | **No provider-side sign-out** | There is no `end_session_endpoint` — see [OIDC support](/docs/reference/oidc-capabilities) | | **Email Push is off by default** | Signing in by sending a pre-filled email (the one-tap email) is enabled with `Channels:Email:PushEnabled` and requires inbound configuration — [Channels](/docs/guides/channels) | ## Operations | Limitation | What to account for | | - | - | | **Key replacement is not seamless** | The configuration accepts one signing and one encryption certificate, so old and new keys never overlap: replacing one invalidates live tokens. Plan it as an operation that invalidates sessions — [Production hardening](/docs/guides/hardening) | | **More than one instance needs shared state** | A SignalR backplane and shared DataProtection keys, otherwise instances cannot read each other's cookies and tokens — [self-hosted, running more than one instance](/docs/quickstart/self-hosted) | | **Rate limiting is per instance** | There are no cluster-wide limits — put a limiter in front of the application if you need them | | **No per-IP limits in the core** | Buckets are counted per route, per user or per client, never per address, and the core does not block addresses. A per-IP limiter and address blocking live on the reverse proxy / WAF — [Production hardening](/docs/guides/hardening#rate-limiting-and-anti-abuse) | ## Channel properties your application will see | Limitation | What to account for | | - | - | | **Telegram carries no email** | And it will not. If your application requires an address, Veriqa collects it inside the sign-in (a required `email`), or your own code supplies it — [Claim enrichment](/docs/concepts/claim-enrichment) | | **`picture` needs the `avatar` scope and comes through userinfo** | The avatar is the image itself, a `data:` URI (from MAX possibly an `https` URL). It is issued only to a client that requests `avatar`, only through `/connect/userinfo`, and never in the `id_token` — [Resolved identity and claims](/docs/concepts/oidc-explainer#resolved-identity-and-claims) | | **On the Email channel `name` equals the address** | There is no display name to take, so the shape of the account depends on the channel | | **`family_name` does not always arrive** | Only when the channel supplied a last name: it is optional in Telegram and MAX, and absent on Email | ## Verified compatibility | Area | Status | | - | - | | WordPress, Drupal, TYPO3, Joomla, Bitrix | ✅ verified — [CMS integrations](/docs/integrations/overview) | | GitLab, Keycloak, Grafana, Nextcloud, Odoo, Moodle | ✅ verified — [business systems](/docs/integrations/apps) | | Discourse | 🟡 signing in an existing user is verified; account creation is not | | Shopify, Wix | ⚠️ connecting to the platform itself is not verified; what it takes — [SaaS builders](/docs/integrations/saas-builders) | | Mattermost | ⚠️ not verified | | A public deployment (real domain + valid certificate) | ⚠️ not verified | | The WhatsApp and MAX channels with client applications | ⚠️ not verified; Telegram and Email are | | The Viber channel on the live platform | ⚠️ not verified: the adapter has been run only against a mock of the Bot API. Since 5 February 2024 Viber issues new bots on commercial terms only, so the bot and its token are obtained from Viber before a deployment — [Viber](/docs/guides/channels#viber) | | Roles, groups and permissions from claims | ⚠️ not verified in any system | ## Deviations from the specifications Two known differences between Veriqa's behavior and what a client may expect — an opaque reference access token instead of a JWT and a strict refusal of `scope` in the token request — are described in [OIDC and OAuth 2.0 support](/docs/reference/oidc-capabilities). That page also states that there is **no** formal OpenID Foundation certification. **Resource indicators (RFC 8707) are not supported.** Veriqa registers no resources, so a `resource` parameter is rejected with `invalid_target` for any value. This behavior is not configurable. Clients written for Microsoft Entra ID often send the parameter anyway (see [Moodle](/docs/integrations/apps)); for such a client the per-client quirk [`drop-resource-parameter`](/docs/guides/client-compatibility) strips it before validation, which is a relaxation of the request form and not support for resource indicators — no audience is granted. # OIDC and OAuth 2.0 support Source: https://veriqa.app/docs/reference/oidc-capabilities What is implemented, what is not, which deviations are known, and whether there is a formal certification. This page answers "what exactly do you support" in one list, so the answer does not have to be assembled from several sections. ## Supported | Capability | Status | Note | | - | - | - | | Authorization Code Flow | ✅ | `code` is the only supported `response_type` | | PKCE | ✅ | `S256` only. `plain` is refused for every client; a public client without PKCE is refused | | Refresh tokens | ✅ | via the `offline_access` scope, enabled per client | | UserInfo | ✅ | `/connect/userinfo` | | Discovery + JWKS | ✅ | `/.well-known/openid-configuration`, `/.well-known/jwks` | | Token revocation (RFC 7009) | ✅ | `/connect/revoke` | | Token introspection (RFC 7662) | ✅ | `/connect/introspect`. The response carries metadata only — `active`, `iss`, `sub`, `jti`, `client_id`, `token_type`, `exp`, `iat`, `nbf`; the user's claims come from `/connect/userinfo`. Enabled per client with `AllowIntrospection`, confidential clients only. A client with this permission sees `sub` and `client_id` of tokens issued to other clients, so grant it only to your own confidential clients | | `iss` in the authorization response (RFC 9207) | ✅ | mix-up protection | | Client authentication | ✅ | `client_secret_basic`, `client_secret_post`, `private_key_jwt` | | `response_mode` | ✅ | `query` (the default), `fragment` and `form_post` — all three advertised in discovery are applied; `form_post` responds with an auto-posting page that submits the response to your `redirect_uri` | | `prompt` | ✅ | `consent`, `login`, `none`, `select_account` | | `id_token` signing | ✅ | `RS256` | | Client Credentials Grant | ✅ | only for creating confirmation transactions server-to-server (`POST /api/transaction/confirmation`) and reading their result; enabled per client with `AllowClientCredentials`, confidential clients only | | Confirmation token grant | ✅ | Veriqa's own extension grant ([RFC 6749 §4.5](https://datatracker.ietf.org/doc/html/rfc6749#section-4.5)), `grant_type=urn:veriqa:params:oauth:grant-type:confirmation`: the client that created a confirmation transaction exchanges it, once the outcome is `confirmed`, for the `id_token` of the person who confirmed. Enabled per client with `AllowConfirmationTokenGrant`, which requires `AllowClientCredentials` and `openid` in `AllowedScopes`. Each transaction is redeemed once; no refresh token is issued. See [linking from your server](/docs/scenarios/channel-linking#linking-from-your-server-without-a-sign-in) | | Scopes | ✅ | `openid`, `profile`, `email`, `phone`, `offline_access`, `channel` and `avatar` — the last two are Veriqa's own, not standard. `avatar` gates the `picture` claim, which is served through userinfo only. A claim the [`Veriqa:ScopesClaims`](/docs/reference/configuration#veriqascopesclaims) catalog lists under a scope goes into both the `id_token` and the access token (and so into userinfo) when that scope is granted, and into no token otherwise — on sign-in, on a refresh and in the confirmation token grant alike, whether the channel gave the value or Veriqa collected it. A custom claim the catalog does not list goes into the access token only | | The `claims` parameter (OIDC Core §5.5) | ✅ | requirement levels only — the claim set is still driven by scopes. A claim in the `id_token` or `userinfo` member marked `essential: true` becomes required: the sign-in does not complete without it; any other claim named there is asked of the user, who may decline. A claim counts only when a requested scope covers it, and a claim Veriqa cannot complete stays issue-if-available (see `Level` in [`Veriqa:ScopesClaims`](/docs/reference/configuration#veriqascopesclaims)). `value` and `values` are not enforced. A parameter that is not a JSON object, or whose `id_token` or `userinfo` member is not an object, is refused with `400 oidc_request_invalid`. Discovery publishes `claims_parameter_supported: false` (the OpenIddict default); the parameter is honored regardless | ## Not supported Listed explicitly so that you do not plan around something absent. | Capability | Status | What it means in practice | | - | - | - | | RP-Initiated Logout (`end_session_endpoint`) | ❌ | signing out of your application does not end the session on the Veriqa side; `revocation_endpoint` exists for revoking tokens, which is a different operation | | Pushed Authorization Requests (RFC 9126) | ❌ | — | | JWT-Secured Authorization Request (JAR) | ❌ | the `request` and `request_uri` parameters are not accepted | | DPoP | ❌ | tokens are not bound to a sender key | | mTLS-bound tokens | ❌ | — | | Dynamic Client Registration | ❌ | clients are declared in configuration | | Device Authorization Grant | ❌ | — | | Resource indicators (RFC 8707) | ❌ | Veriqa registers no resources, so a `resource` parameter is rejected with `invalid_target` for any value — that is the default and it does not change. Clients written for Microsoft Entra ID send the parameter regardless; for those, the per-client quirk [`drop-resource-parameter`](/docs/guides/client-compatibility) strips it before validation instead of granting any resource | ## Known deviations Two places where Veriqa's behavior differs from what a client may expect. Knowing about them in advance is enough. **The access token is an opaque reference token, not a readable JWT.** The client receives an identifier; the token content stays in the Veriqa OpenIddict database. The refresh token has the same form. A client that tries to parse the access token as a JWT gets an error or a log warning — Grafana, for one, behaves this way. It does not affect sign-in: the access token is meant for the Veriqa server, and the client takes session content from the `id_token` and UserInfo. An API of yours checks an access token through [introspection](#supported), not by keys. Earlier builds issued the access token as an encrypted JWE; such tokens are still accepted until they expire. **The `scope` parameter is rejected in the token request.** [RFC 6749 §4.1.3](https://www.rfc-editor.org/rfc/rfc6749#section-4.1.3) does not define it for `grant_type=authorization_code`, and the Veriqa server follows it strictly. Two known clients send it anyway — the `openid-connect-generic` plugin for WordPress and `omniauth_openid_connect` for GitLab; how to clear it on their side is described on the [WordPress](/docs/integrations/wordpress) and [Veriqa in business systems](/docs/integrations/apps) pages. ## Certification **There is no formal OpenID Foundation certification.** WordPress, Drupal, Nextcloud, Keycloak, Discourse and Odoo verify and accept the Veriqa `id_token` signature against JWKS — six independent implementations — and Keycloak configures itself entirely from the Veriqa discovery document. # 0.6 — first public release Source: https://veriqa.app/docs/releases/0.6 Veriqa 0.6, the first public release: sign-in and confirmation through trusted messengers behind a standard OpenID Connect provider, embedded in .NET or run as a server. Veriqa 0.6.0 was published on September 23, 2026 — the first release anyone can install. To your application Veriqa is a standard OpenID Connect provider; the user confirms the sign-in in a messenger or a mailbox they already use — **Telegram, WhatsApp, other channels or Email**. ## What's in it ### Channels * **Built in:** four channel adapters. Each channel is a separate adapter and is switched on in configuration. [Supported channels](/docs/guides/supported-channels) covers what each one adds to the claims and whether the user has to send a message; [Channel setup](/docs/guides/channels) walks through setup end to end. * **Email in two modes:** the classic magic link, on by default, and the one-tap email, where the user sends a pre-filled email — [which mode to choose](/docs/guides/channels#which-mode-to-choose). * **A channel of your own** as a .NET adapter inside your host — [Build your own channel adapter](/docs/guides/custom-channel-adapter). ### Deployment modes * **Embedded in ASP.NET Core.** Veriqa extends OpenIddict in your own host and leaves token issuance to it — [Quickstart .NET](/docs/quickstart/dotnet). * **Self-hosted server in Docker.** Your application stays a plain OIDC client on any stack — [Quickstart self-hosted](/docs/quickstart/self-hosted), with client quickstarts for [Node.js](/docs/quickstart/node), [Java](/docs/quickstart/java) and [Python](/docs/quickstart/python). * **OS service.** The same server from a self-contained archive as a systemd unit or a Windows service — no Docker and no .NET runtime on the machine — [Install as an OS service](/docs/quickstart/os-service). ### Scenarios * [Passwordless sign-in](/docs/scenarios/passwordless-login) — a QR code on screen, confirmation in a messenger, sign-up in the same step. * [Sign-in with channel linking](/docs/scenarios/channel-linking) — the user signs in and the product gains a live channel. * [Second factor and step-up](/docs/scenarios/step-up) — a fresh confirmation before a sensitive operation. * [Replacing SMS codes](/docs/scenarios/sms-replacement), [leads without CAPTCHA](/docs/scenarios/lead-capture) and [account recovery](/docs/scenarios/account-recovery). * [Sign-in surfaces](/docs/scenarios/surfaces) — smart TV, kiosk, a device with no keyboard. * [Server-to-server confirmation](/docs/reference/confirmation-api) — your backend asks a person to confirm an action, such as a payment or a data transfer, with no sign-in and no redirect; [Confirmed form submission](/docs/integrations/confirmed-form) builds on it. ### Also included * Three ways to learn that the confirmation happened: the redirect back, live status over SignalR and status polling — [How a sign-in completes](/docs/concepts/completion). * A transaction store on InMemory, EF Core or Redis, and the OpenIddict database on PostgreSQL or SQL Server — [Storage](/docs/guides/storage). * The [initiator context](/docs/concepts/initiator-context) in the confirmation message — who started the sign-in and from where, with an optional offline GeoIP provider. * An audit trail, off until you enable it — [Security and data handling](/docs/guides/data-handling). * Sign-in page design, localization and extension points — [Configuration & customization](/docs/guides/customization). * Verified integrations with [CMS engines](/docs/integrations/overview) and [business systems](/docs/integrations/apps). ## Where to get it * **NuGet packages** on nuget.org, version `0.6.0` for all 26 packages. The core is MPL-2.0; `Veriqa.Core.Contracts`, `Veriqa.ServiceDefaults` and `Veriqa.Samples` are MIT. * **Docker image** `veriqa/veriqa` on Docker Hub, tags `0.6.0` and `latest`, `linux/amd64`: `docker pull veriqa/veriqa:0.6.0`. * **OS service archives** for `linux-x64`, `linux-arm64` and `win-x64` with a SHA-256 sums file — attached to the repository's releases; [Install as an OS service](/docs/quickstart/os-service) explains how to download and verify them. * **Source code** — the repository at [veriqa.app/source](https://veriqa.app/source). | Area | Packages | | - | - | | Server and engine | `Veriqa.Core.AuthServer`, `Veriqa.Core.TransactionEngine`, `Veriqa.Core.Configuration`, `Veriqa.Core.Contracts` | | Channels | `Veriqa.Core.ChannelAdapter`, `Veriqa.Core.BaseChannels` (metapackage: Telegram, WhatsApp, Email), `Veriqa.Core.ChannelAdapter.Telegram`, `Veriqa.Core.ChannelAdapter.WhatsApp`, `Veriqa.Core.ChannelAdapter.Email`, `Veriqa.Core.ChannelAdapter.Max`, `Veriqa.Core.ChannelAdapter.Redis` | | Stores and events | `Veriqa.Core.TransactionEngine.EntityFrameworkCore`, `Veriqa.Core.TransactionEngine.Redis`, `Veriqa.Core.TransactionEngine.RabbitMq`, and the PostgreSQL and SQL Server migrations: `Veriqa.Core.TransactionEngine.Migrations.*`, `Veriqa.Core.AuthServer.Migrations.*` | | Audit trail | `Veriqa.Core.AuditTrail`, `Veriqa.Core.AuditTrail.EntityFrameworkCore`, `Veriqa.Core.AuditTrail.Migrations.*` | | Other | `Veriqa.Core.AuthServer.MaxMind`, `Veriqa.ServiceDefaults`, `Veriqa.Samples`, `Veriqa.Core.ChannelLocales` | ## 0.6.1 A maintenance update with no change in behavior: package descriptions, search tags and package READMEs. ## Known limitations [Known limitations](/docs/reference/limitations) lists what 0.6 does not have and what behaves differently than you might expect; deviations from the OIDC specifications are in [OIDC and OAuth 2.0 support](/docs/reference/oidc-capabilities). # 0.6.2 — hop mode, the HTTP channel and claim completion (upcoming) Source: https://veriqa.app/docs/releases/0.6.2 What Veriqa 0.6.2 brings: hop mode for the QR code, a channel in any language over HTTP, Twilio for WhatsApp, claim completion and the phone number from the channels. <Note> This version is being prepared for release: nothing described here is in the published packages and image yet. The latest release is [0.6.1](/docs/releases/0.6#061). </Note> ## Hop mode The QR code carries a short one-time link to your Veriqa server instead of the channel link. That gives a light QR code, a scan signal on the computer, a launch page on the phone, one QR code for every channel and the context of the scanning device. It is off by default; we recommend turning it on — see [Hop mode](/docs/concepts/hop-mode). ## A channel in any language over HTTP A new package, `Veriqa.Core.ChannelAdapter.Http`: your own channel is a separate service in any language, connected by configuration, with no rebuild of the host. A schema describes the wire contract, and a conformance suite checks your service before you connect it — see [An HTTP channel adapter in any language](/docs/guides/custom-channel-adapter#an-http-channel-adapter-in-any-language). ## WhatsApp through Twilio Twilio is a built-in WhatsApp delivery provider next to the Meta WhatsApp Business Platform; one key selects it — see [Delivery through Twilio](/docs/guides/channels#delivery-through-twilio). ## Claim completion and the phone number * **The phone number from the channel itself:** the user shares it with a button in the chat — see [Phone number](/docs/guides/phone-number). * **Missing claims completed inside the sign-in** — a form on the sign-in page or on the confirming device, and ways to verify an email address — see [Progressive claim enrichment](/docs/concepts/claim-enrichment). * **A collected claims store** on EF Core, with deletion by `sub` — see [Collected claims store](/docs/guides/storage#collected-claims-store). ## Security * A confirmation is bound to the channel subject and to the browser the answer came from. * The interstitial cookie is bound to the authorize request, GeoIP reads only a trusted IP, and the per-user rate limiter evicts old partitions. * The response to creating a server-to-server confirmation returns the channel's `display_name`. ## What to check when you upgrade * **The `sub` of WhatsApp users changes.** It was `whatsapp:+number` and is now `whatsapp:{BSUID}`, the ID Meta assigns per business portfolio; the number moves to `preferred_username`. There is no migration: existing WhatsApp users get a new `sub` in your application. * **`ShowQrCode` defaults to `DesktopOnly`** instead of `Always`: a phone shows channel buttons instead of a QR code. To keep the old look, set `"ShowQrCode": "Always"`. * **New EF Core migrations** for the transaction store and the audit trail — apply them when you upgrade; see [Storage](/docs/guides/storage). # 0.6.3 — the sign-in page and channel restriction policies (upcoming) Source: https://veriqa.app/docs/releases/0.6.3 What Veriqa 0.6.3 brings: a cancel button and layouts for the sign-in page, per-application page texts, channel restriction policies, one-tap email over IMAP. <Note> This version is being prepared for release: nothing described here is in the published packages and image yet. The latest release is [0.6.1](/docs/releases/0.6#061). </Note> ## The sign-in page * **A cancel button**, on by default — see [Cancel button](/docs/guides/customization#cancel-button). * **Channel button visibility and an "all channels at once" layout** — see [Channel button visibility](/docs/guides/customization#channel-button-visibility) and [Channel layout](/docs/guides/customization#channel-layout). * **Your own page texts for one application** — the `AuthPage.Texts` dictionary covers the sign-in page, the hop pages and the Email pages; see [Rewording a page element for one application](/docs/guides/customization#rewording-a-page-element-for-one-application). * If resubscribing after a reconnect fails, the page falls back to polling the status; an expired sign-in stops the spinner on every page. ## Channel restriction policies Anyone with a messenger can confirm a sign-in. Policies narrow that circle by what the channel reports about the person confirming: only corporate email domains, only phone numbers from the countries you name, or only users whose channel passes a phone number. They are set per channel in configuration and are off by default. This is a filter on channel data, not a cryptographic barrier: a country code is a number prefix, not where the person is. The keys are `RestrictionPolicy.*` in the [key catalog](/docs/reference/configuration#keys-in-the-declaration-catalog). ## Channels * **Email: one-tap email over IMAP** — no inbound mail provider and no public address needed, with mailbox sign-in by password or OAuth 2.0; see [One-tap email over IMAP](/docs/guides/channels#one-tap-email-over-imap). * **An action after sign-in** — `CompletionBehavior`: a welcome message and a post-action in the chat; see [Veriqa:CompletionBehavior](/docs/reference/configuration#veriqacompletionbehavior). * The text that asks for the phone number is an ordinary message kind, `phone-number-request`, and is edited like any other channel message. * MAX in webhook mode parses a shared contact correctly. ## Documentation A new [Observability](/docs/guides/observability) page: metrics, traces, logs and health checks. ## What to check when you upgrade * **The Cancel button appears on every sign-in page.** If you built your own through `CustomJsPath`, remove it — or turn the built-in one off. * **Phone numbers are accepted in E.164 only.** A custom or HTTP adapter that reports a number in another form no longer passes it on. The rule is the public `E164PhoneNumber` in `Veriqa.Core.Contracts`. # 0.6.4 — Instagram, sign-in page texts and PathBase (upcoming) Source: https://veriqa.app/docs/releases/0.6.4 What Veriqa 0.6.4 brings: the Instagram channel, markup in sign-in page texts, a host under a path prefix, the Menu and Redirect post-actions. <Note> This version is being prepared for release: nothing described here is in the published packages and image yet. The latest release is [0.6.1](/docs/releases/0.6#061). </Note> ## Instagram Instagram Direct is a built-in channel of the core: the user signs in through a conversation with your Instagram account — see [Instagram](/docs/guides/channels#instagram). ## The sign-in page * **Inline markup in `Title`, `Instruction` and a channel hint** — see [Markup in the title, the instruction and a channel hint](/docs/guides/customization#markup-in-the-title-the-instruction-and-a-channel-hint). * The built-in instruction names only what the page shows, and a single-channel page names its channel. A new page for an expired sign-in. * The claim completion state is visible in the channel; the Telegram phone keyboard is removed after the answer; "Cancel sign-in" sits under the completion form. ## Channels The `Menu` and `Redirect` post-actions for Telegram and MAX — what the bot shows after the sign-in; see [Veriqa:CompletionBehavior](/docs/reference/configuration#veriqacompletionbehavior). ## Operations A host under a base path prefix — `Veriqa:PathBase`; see [Under a path prefix](/docs/guides/reverse-proxy#under-a-path-prefix). ## What to check when you upgrade: sign-in page wording Your own wording set for one of the built-in texts below — in `AuthPage:Texts` or in a locale file — stops applying where the page no longer shows that text. Move it to the new key: | Where | Key that stops applying there | Key to move the wording to | | - | - | - | | Expired sign-in | `Session expired` | `This sign-in request has expired` | | Single-channel page — whatever it shows | `Scan the QR code or tap the channel button` · `Tap the channel button to sign in` | `Scan the QR code or tap the button to sign in via {0}` · `Scan the QR code to sign in via {0}` · `Tap the button to sign in via {0}` | | Several channels with their own QR codes, buttons hidden | `Choose a channel and scan the QR code or tap the button` | `Choose a channel and scan the QR code` | | Several channels behind one QR code, buttons hidden | `Scan the QR code or tap the channel button` | `Scan the QR code to sign in` | The earlier keys stay in force only on a multi-channel page that shows a button. A replacement for a single-channel text must carry the `{0}` slot, where the page puts the channel's icon and name. Delete the earlier docs recipe for a screen with hidden buttons — the `tv-app` example: the instruction now follows the button by itself. The full table of variants is in [The instruction follows both settings](/docs/guides/customization#the-instruction-follows-both-settings). **`Title`, `Instruction` and a channel hint already set as plain text may look different:** these texts are now read as HTML. `Terms & Conditions` now shows "Terms & Conditions", and a bare `<` or `>` fails the check — the value is shown as written and a warning goes to the log. Review your values for `&`, `<` and `>`. # 0.6.5 — Messenger, WeChat and Viber (upcoming) Source: https://veriqa.app/docs/releases/0.6.5 What Veriqa 0.6.5 brings: the Messenger, WeChat and Viber channels, a dark theme for service pages, hop mode per application, superseding unfinished sign-ins. <Note> This version is being prepared for release: nothing described here is in the published packages and image yet. The latest release is [0.6.1](/docs/releases/0.6#061). </Note> ## Three new channels [Messenger](/docs/guides/channels#messenger), [WeChat](/docs/guides/channels#wechat) and [Viber](/docs/guides/channels#viber) become built-in channels of the core. Viber was tested against a Bot API stand-in, with no live run. A refusal from the Messenger, Instagram and Viber clients now comes back as a result instead of an exception, and a repeated "Yes" or "No" no longer sends a new message. ## The sign-in page and hop mode * Service pages follow the `Theme` setting, the dark theme included. * Hop mode can be turned on for one application or tenant — see [Hop mode for one application](/docs/reference/configuration#hop-mode-for-one-application). * Sign-in errors are returned to the client with a redirect. ## Sign-ins and transactions * **A new sign-in supersedes the same user's unfinished ones** — see [A new sign-in closes the earlier ones](/docs/guides/storage#a-new-sign-in-closes-the-earlier-ones). * **A sign-in survives the issuing of the authorization code:** for a sign-in already redeemed, the callback answers that the session has expired. * **Two linked bots** — the `{handle}` slot; see [Two bots](/docs/guides/existing-bot#two-bots). * The HTTP channel passes the phone number, on request or automatically. ## What to check when you upgrade * **A new EF Core migration** for the transaction store — apply it when you upgrade. * **Parallel sign-ins of one user:** an unfinished sign-in closes as soon as the same user starts a new one. If your scenario keeps two sign-ins open at once, check it. # 0.6.6 — sign-in by a code and fixes from live runs (upcoming) Source: https://veriqa.app/docs/releases/0.6.6 What Veriqa 0.6.6 brings: a fallback sign-in code, Redis and RabbitMQ in the standalone server, a new QR code mode, Email under hop mode and fixes from live runs. <Note> This version is being prepared for release: nothing described here is in the published packages and image yet. The latest release is [0.6.1](/docs/releases/0.6#061). </Note> ## Sign-in by a code Where the channel link does not carry the transaction to the bot, the sign-in has a second path: the page shows a short code, and the user sends it to the bot. For Instagram and Messenger the code is offered automatically; under hop mode it is on the phone's launch page and disappears as soon as the bot answers. `BotName` sets the bot's name. The HTTP channel supports a start by a code for registrations with no owner — see [Start by a code](/docs/guides/custom-channel-adapter#start-by-a-code--when-the-link-cannot-be-relied-on). ## The sign-in page and hop mode * The hop pages and the sign-in page show where the question is waiting. * Canceling on the sign-in page sends a "declined" receipt into the channel. * A third QR code mode, `QrMode: UnifiedWithChannels`: the shared QR code and the buttons of every channel on the first tab, followed by a tab per channel with its own QR code. * Email under hop mode: one page with the address field, or the email right away — see [Email under hop mode](/docs/guides/channels#email-under-hop-mode). * Fixes from live runs: the countdown and the live status of the `OnWebPage` question page, the "Choose another way" button. ## The standalone server The standalone server image includes the Redis stores, event publishing to RabbitMQ and MaxMind GeoIP — switched on by configuration keys, with no build of your own; see [Quickstart self-hosted](/docs/quickstart/self-hosted). ## Samples and documentation * Google and Apple pseudo-channels — `samples/dotnet/pseudo-channels`. * A Claude Code mod with a QR code panel — `samples/claude-code`; see [Approvals in Claude Code](/docs/research/claude-code-approvals). * The [Helio](/docs/samples/showcase/support-desk) and [Nova Stream](/docs/samples/showcase/streaming-tv) showcases. * A new page, [You already have a bot](/docs/guides/existing-bot). ## What to check when you upgrade * **Email with `LoginConfirmation.Mode = OnWebPage`:** the sign-in email no longer completes the sign-in; it only links the mailbox, and the sign-in page decides. "No" there gives `declined_by_user`. * Some built-in texts are reworded — if you override them, check them against [Configuration & customization](/docs/guides/customization). # Coming next Source: https://veriqa.app/docs/releases/next What the next two Veriqa releases are preparing: pseudonymized channel identifiers, anomaly detection, sign-in with Google, Apple and Huawei as a separate service, and device binding levels. <Note> These are plans, not a commitment to dates: what each release contains may change. </Note> ## Next release: privacy and risk signals **Pseudonymized channel identifiers.** Today `channel_user_id` is the platform's native identifier: a Telegram ID, a WhatsApp ID, an email address. A new tenant mode replaces it below the channel adapter with a deterministic pseudonym under a key Veriqa does not store, and trims the set of stored claims. A database leak no longer links a person to the address that reaches them in a messenger. **Anomaly detection for the initiator context.** Veriqa remembers derived, non-personal signals of confirmed sign-ins — the country and the device type — and flags a sign-in that does not match them. IP, User-Agent and city are not stored. It is opt-in: until you turn it on, nothing changes. ## The release after: new ways to sign in and device binding **Sign-in with Google, Apple and Huawei as a separate service.** A container next to Veriqa, connected by configuration through the HTTP channel: the sign-in device shows a QR code, the user signs in to their account on the phone and confirms on a "Yes / No" page. It helps on devices with no keyboard and for people who do not use any messenger. **Binding levels between the initiator and the confirmer.** Levels of trust between the device where the sign-in started and the device where it is confirmed — from the base level up to a "same device" policy for scenarios where confirming from someone else's phone must not be possible. # Approving AI-agent actions Source: https://veriqa.app/docs/research/agent-approvals Human consent on a surface the agent does not control: what the product already has for it and what this construction still lacks. A software agent acts on a person's behalf, and someone has to say "yes" to the risky part of that work — consent that is worth little if it is collected on the very screen the agent controls. Why this matters and for whom is covered on the [Approving AI-agent actions](https://veriqa.app/whats-next/agent-approvals?lang=en) page. Here is the engineering side. ## What it builds on The direction needs no separate protocol: confirming an action is the same transaction as confirming a sign-in, only with a different purpose. A transaction with an explicit state and a single outcome, a message in a trusted channel, the person's answer and an entry in the audit trail — all of it already exists in Veriqa's core. For confirming an action rather than a sign-in, the product has: 1. **Server-to-server creation of a confirmation transaction.** The application calls `POST /api/transaction/confirmation`, authenticating with client credentials — no OIDC request and no user redirect. The response carries the entry point for the person: the channel's deep link and the same address as a QR image, with a "Powered by Veriqa" attribution strip under the code. The application reads the outcome with `GET /api/transaction/{id}/result`. 2. **Concrete wording of the confirmation.** The request names a declared action type (`action_type`), and the person sees not "confirm sign-in" but that action's message: which action, over what, with which parameters. 3. **A safe way to carry the action's data.** Free-form text from the application never reaches the user's message — that is a direct route to substituting the meaning of the confirmation. The data travels in typed slots (`slot_values`) against the schema declared for the action type and is validated before the transaction is created. ## Available today: an agent whose tools you know When you build the agent yourself and its tools are known in advance, nothing more is needed. The approval goes where the tools are invoked, not into the prompt: 1. Each tool with consequences gets an **approval policy** in your code: which declared action type asks the person, and which arguments of the call fill its slots. 2. The code that invokes tools checks the policy. For such a tool it creates the confirmation, shows or sends its entry point, and waits for the outcome. 3. The tool runs only on `confirmed`. On `declined` or `expired` the harness returns a refusal as the result of the call, the same way it reports any other failed call to the model. Whatever the model decides to call, a tool with a policy does not run without a confirmed transaction. The person reads the slot values of a declared action type, never text the agent wrote. The runnable example is `samples/dotnet/inproc/agent-approval` in the repository. A support agent handles a refund ticket with three tools: `lookup_order` and `send_customer_email` run on their own, while every `refund_payment` call waits for a person to approve it in Telegram. The run log records who approved. For a single destructive action in an ordinary application — deleting a project, closing an account — see `samples/dotnet/inproc/step-up`: there the backend also checks that the person who confirmed is the owner of the account (`expected_identities`). For a harness whose tools you do not write, `samples/claude-code` shows the same approval plugged into Claude Code through its hooks — see [Approvals in Claude Code](/docs/research/claude-code-approvals). ## What does not exist today What is missing is the generic layer for agents whose tools the integrator does not write — agent platforms and harnesses where an approval should plug in without code of your own: * **a vocabulary of actions** — a shared language for "what are we confirming", rather than a free-form set of action types per integrator; * **an integration surface** for agent platforms and tool protocols, such as an approval step a tool server declares by itself; * **approval policies** around confirmations: what always requires a human, what only above a threshold, what can be granted once and reused. ## Where its limits are The direction inherits the boundary of the rest of Veriqa: confirmation rests on the person's trusted channel and on the API of that channel's platform. <Warning> This is a strong and convenient step for scenarios where such trust has a place, but **not cryptographic proof of intent**. Taking part in the scheme does not turn a messenger into a hardware security module. Where the risk model demands more, use stronger measures. </Warning> # Stronger sign-in with a chain of channels Source: https://veriqa.app/docs/research/auth-chain One transaction confirmed in several channels: how it is designed — and why this is not strict MFA. A sign-in that needs confirmation not in one trusted channel but in several: the same transaction is confirmed first in, say, Telegram and then over email, and there is no partial success. Why this matters and for whom is covered on the [Stronger sign-in with a chain of channels](https://veriqa.app/whats-next/auth-chain?lang=en) page. Here is how it is designed. ## How it is designed * **The linked-identity model** — several channel identities joined into one record, and the rule by which its stable identifier becomes the user's identifier for the application. The order of confirmations does not affect it, and adding or losing a channel later does not turn the user into someone else. * **The "K of N" policy** — the application requires confirmation from K of the N linked channels. A chain of two is the special case where K and N are both two. * **Linking and recovery** — how channels enter the set and what happens when one of them is lost. * **The default** — off. For applications that have not enabled the chain, nothing changes. A chain adds friction by definition, so it is meant to be switched on by the application for sensitive actions and step-up rather than for ordinary sign-in. ## Where its limits are <Warning> **A chain is not strict multi-factor authentication.** Both confirmations are of the same nature: possession of a channel plus trust in that channel's platform. Two messengers on one phone are lost together with the phone; a SIM swap can affect several at once. </Warning> To be equally clear: a chain **does not make sign-in phishing-resistant** and adds no cryptographic barrier between the browser and the phone. It increases the number of independent confirmations, not the strength of the binding between them. The gain is real and specific all the same: an attacker needs control over two independent channels instead of one, which is a markedly higher bar than a single stolen messenger session. The most useful pair is a messenger and email — the two channels least likely to fail together. That is a recommendation to whoever configures it, not a rule the product enforces. ## What not to confuse with a chain An attribute obtained inside a channel is not another link. When a user shares their phone number with one tap in a messenger and the platform itself confirms the number, the chain still has the same number of channels: this is enrichment of the identity, not an additional confirmation. Its value is that a verified number arrives without sending, paying for or waiting on an SMS code. # Approvals in Claude Code Source: https://veriqa.app/docs/research/claude-code-approvals A sample plugin that stops chosen Claude Code actions until a person approves them in a messenger through Veriqa: tool calls, long command cycles and costly runs. The sample shows how Veriqa fits into a ready-made agent harness: a person approves sensitive agent actions — not only tool calls, but also heavy runs that burn time and tokens — and every approval, refusal and expiry lands in the Veriqa audit trail. This matters most for teams where several developers work with agents at once. Claude Code and development tasks are the example chosen here; the approach fits another harness and other tasks. The sample lives in `samples/claude-code/` in the repository ([veriqa.app/source](https://veriqa.app/source)), is MIT-licensed and is meant to be reworked for your own needs. Its README is the setup guide. ## Three demos | Demo | What is stopped | Action type | | - | - | - | | A tool call | `git push` and `rm -r…` in the Bash or PowerShell tool | `agent-tool-call` | | The start of a long command cycle | `/cycle`, `/cycle-chain` — typed by a person or started by the agent | `agent-command-start` | | A costly run with an estimate | a run estimated at 1 billion input and 50 million output tokens, about 6250 USD | `agent-costly-run` | The costly run takes **two approvers in turn** — a developer, then a manager — each from their own messenger account enrolled beforehand. A confirmation from any other account refuses the action. ## How it works The plugin is a Claude Code mod — a plugin of function hooks that runs inside Claude Code 2.1.295 or later — so the approval happens in the harness itself, without the model taking part. 1. A hook of the plugin holds the tool call or the slash command and matches it against its rules. If nothing matches, Claude Code continues as usual. 2. If a rule matches, the plugin creates a confirmation with `POST /api/transaction/confirmation` (client credentials). The action waits; nothing has run. 3. The plugin opens a pane of its own with what is asked, the QR code Veriqa returned and the link. On a narrow terminal its status line says what is waited for, and a command opens the pane. 4. The hook waits for the outcome, reading `GET /api/transaction/{id}/result` until the transaction ends. The action runs only on `confirmed`; declined, expired, refused in the pane, a confirmation from the wrong account or an unreachable host all refuse it. In a chain the pane moves on to the next approver's code. The person sees wording declared on the host with typed slot values filled in — "Claude Code in demo-shop wants to push the branch to the remote repository" — never text the agent wrote. ## Channels of the sample The person answers in Telegram by default — the host reads it by long polling, so the demo works on `localhost`. Messenger, WhatsApp and Instagram, or a page where the person picks one of these channels on the phone, need the host on a public HTTPS address: Meta delivers the answer by webhook, and the phone has to reach the page. ## Why not the harness's own "Allow" button * **A named person.** The host checks which messenger account confirmed, not merely that someone pressed a button. * **Someone other than the developer.** A chain asks a second person, so nobody signs off their own spending. * **A trail outside the agent's session.** Approvals are recorded on the Veriqa host, not in a local transcript the same user can edit. * **A question the agent does not write,** answered on a separate device over a channel the agent does not control. For a single developer watching their own agent, the harness button is often enough; the difference shows when several people share agents, spending or responsibility. ## Demo shortcuts <Warning> Veriqa runs on the developer's machine only to make the demo quick to set up; in a team the approving host is a shared service. The second approver's QR code is shown in the same pane for the demonstration only — Veriqa does not deliver the question to that person, and in a real setup the link reaches them another way: their own chat, email, a ticket. </Warning> The plugin puts a person in the loop; it is not a sandbox. The limits are listed in the sample's README. The engineering background of the direction is on [Approving AI-agent actions](/docs/research/agent-approvals). # Honest voting on any website Source: https://veriqa.app/docs/research/honest-voting A vote confirmed in the voter's messenger and a result that can be checked: what the product already has for it, what this direction still lacks and where its limits are. Online votes — a fan favorite, a contest, an award — are too often decided by whoever bought the votes. A CAPTCHA is solved by a paid service for a fraction of a cent, an email address costs nothing, and a social account costs a few cents. The organizer has nothing to answer an accusation with but a promise. The direction is a voting widget that can be embedded on any website. Every vote is a Veriqa confirmation in the voter's messenger — one tap, with no sign-up, no password and no code to type — and the result comes with a signed receipt that the voter and a third party can check. In one line: a vote whose result can be shown, not just promised. ## What it is for * **The voter** votes without an account, and the receipt lets them check that their vote is in the total. * **The nominee** gets a win that can be proved. A victory nobody can verify is worth little once someone says it was bought. * **The organizer** moves from "trust us" to "check for yourself", and does not have to store phone numbers to fight fraud. * **The sponsor** gets a result that can be audited, and reach measured in people rather than clicks. * **The site developer** embeds a widget instead of building verification, number storage and their own anti-fraud. A campaign that runs in several networks at once gets one more thing. Today the votes from each network are added up, and the same person counts once per network. A phone number is the one identifier all the networks share: if the posts in every network lead to one ballot, one person is one vote whatever the entry point, and the organizer sees how many unique people each network brought and how far the audiences overlap. The cost is one extra step: a poll built into the network itself — a channel poll or a Stories sticker — has nowhere to put a confirmation. ## How strong the protection is No anti-fraud is perfect, and the direction does not claim immunity. It claims fraud that is **more expensive** than with the methods common on websites today: a CAPTCHA or an email confirmation costs a fraud farm next to nothing, a social account a few cents, and an identity anchored to a phone number roughly an order of magnitude more than a social account. SMS verification uses the same anchor, and against it the protection is on par. The difference lies elsewhere: messages in a messenger are not billed per message, and the voter does not retype a code. The same trade-off as in [Replacing SMS codes](/docs/scenarios/sms-replacement): the phone anchor at the price of a messenger wherever the audience has one. ## What it builds on A vote is not a separate product but a confirmation of an action carrying data — which poll and which choice. That transaction already exists: 1. **Server-to-server confirmation.** The website's server calls `POST /api/transaction/confirmation` with a declared action type (`action_type`) and receives the channel's deep link and a QR code for the voter. The outcome is read with `GET /api/transaction/{id}/result` — `confirmed`, `declined`, `expired` or `failed` — and the response does not disclose who confirmed. 2. **The exact subject in the message.** The poll and the choice travel in typed slots (`slot_values`) against the schema of the action type, and the voter sees in the chat what they are voting for, with Confirm and Decline buttons. 3. **The phone number as the key.** WhatsApp delivers `phone_number` — with the message, or through the phone step when the user hides the number behind a username. Telegram hands over an account identifier rather than a number — see the [channel catalog](/docs/guides/supported-channels). 4. **The audit trail.** The trail is append-only; a deployment can explicitly turn on recording the parameters of the confirmed operation, and user identifiers appear in it only masked. ## What does not exist today * **One person — one vote.** The product confirms an action but neither counts votes nor deduplicates them within a poll. * **A key that does not keep the number.** Deduplication needs the phone, but the record should hold a one-way keyed hash, different for every poll, rather than the number itself — the approach described in [Resilience to a personal-data leak](/docs/research/leak-resilience). In Telegram the number is available only after the user explicitly shares it. * **A signed receipt and a checkable total.** The voter needs a receipt, and the poll a published summary against which the voter checks that their vote is included and a third party checks that the total matches the register. The message Veriqa sends after a confirmation is text for the user, not an attestation, and the audit trail is not tamper-evident. * **The anchor level as a property of the poll.** Not a "protected" badge but "collected on an anchor costing about this much per identity", together with the checks around the key: virtual number ranges, bursts of votes from one network, repeated patterns. * **The widget and the service behind it.** A widget on an arbitrary website means the voting is hosted by Veriqa rather than by the integrator: allowed origins, rate limits, an abuse policy. * **Email** does not fit this transaction: an address costs nothing, so a vote confirmed by email anchors nothing. A voting or contest platform that accepts an external OpenID Connect provider can connect Veriqa as its sign-in method today, like any other OIDC client — see [SaaS builders](/docs/integrations/saas-builders) for how that connection works. Whether the platform lets a public audience sign in through an external provider is up to that platform. The platform then counts the votes itself, so the anchor and the one-tap confirmation are available this way, while the receipt and the checkable total are not. ## Where its limits are <Warning> A confirmed vote is **attributable**: it is tied to the voter's confirmation. This fits a fan favorite, a contest or an award. It does not fit elections or any decision with legal consequences, where the secrecy of the ballot matters. </Warning> * **Votes bought from real people are not stopped by anything here.** The phone anchor raises the price of a vote to the cost of a person's time and stops there. * **The receipt shifts trust but does not remove it.** It proves that the website did not alter the result; it does not prove that Veriqa did not issue fictitious identities. * **Coverage.** Where a large share of the audience uses none of the supported messengers — in the US, for example, no single messenger dominates — the widget cannot be the only way to vote, and any fallback without a phone anchor brings the guarantee down to its own level. * **The receipt is an attestation of the count**, not an electronic signature in the legal sense. # Resilience to a personal-data leak Source: https://veriqa.app/docs/research/leak-resilience The link between a person and the way to reach them is the most toxic thing in a connector's database. What Veriqa stores today, how the mode is designed and what it does not give you. Any connector between an application and a messenger has to answer which messenger account belongs to this user — and the usual answer is stored for as long as the account itself lives. Why that column is more toxic than a password is on the [Resilience to a personal-data leak](https://veriqa.app/whats-next/leak-resilience?lang=en) page. Here is what exists today and what it is meant to become. ## What Veriqa stores today Start with the current state, because that is what you make a risk decision on. **The channel identifier and the profile are stored in the clear.** The key of a channel identity is the pair "channel type + user identifier within it", and the profile sits next to it: display name, first and last name, username, email address, phone number, avatar, locale. There are no derived forms along the storage path, and the record has no lifetime either. This is exactly the behavior any connector has — but it is better to know it in advance. **Masking today covers the journals only.** By contract the audit trail and the logs write neither tokens nor unmasked identities, and that works. The rule does not touch the main store. **And a qualification the risk decision turns on: out of the box that store does not exist.** Veriqa ships no implementation of the port that would persist a channel identity and registers none, so a stock installation keeps the profile only for the minutes the transaction lives. Everything described above starts applying the moment you register an implementation of your own. The full inventory — what is kept where, for how long, and what is encrypted — is in [Security and data handling](/docs/guides/data-handling). <Note> The encryption the product already has applies to **channel provider secrets**, not to user identifiers. The hardening guidance covers secrets and the database connection — not identifiers. </Note> ## How the mode is designed The mode is designed as the integrator's decision — a tenant-level mode switched on deliberately, not a default that changes behavior for everyone. It comes down to four properties, and they are connected. **A derived value instead of the native identifier.** The record holds a one-way hash keyed with a secret, not the identifier itself. The same person always yields the same value — the account is recognized on every sign-in — but the value does not unwrap back into a Telegram id or an email address. **The secret does not live next to the data.** It belongs in a KMS or a secret store, self-hosted deployments included, with no concessions. This is not hardening advice but a condition of the scheme working at all: the space of possible inputs is small enough to be enumerated in full, and a secret inside the same snapshot as the database would let an attacker simply recompute the function. Where there is no secret store, the mode is designed **not to switch on** rather than to degrade into something weaker. **Different integrators get different values.** The tenant takes part in the computation, so the same person becomes a different value in each integrator's database. Two leaked databases do not join to each other, and neither joins to a purchased marketing list. **And where the scenario allows — no value at all.** If the user always arrives from the channel, the identifier comes with every confirmation and there is nothing to keep between transactions. The strongest form of the property is not "well encrypted" but "never written down". ## What it costs Both consequences follow from the property itself rather than qualifying it: * **Finding a user by their Telegram id or email address becomes impossible.** That is exactly what is being paid for. * **A scenario that pushes a confirmation into the channel itself** does need the native identifier back. There it is designed to live encrypted under the same service's key — reversible, unlike a hash: stronger than storing it in the clear, weaker than a mode that stores nothing. ## What it does not do <Warning> **The data does not leave the scope of GDPR or national personal-data law.** Pseudonymization there is a safeguard (GDPR Art. 4(5) and Art. 32(1)(a)), not anonymization: the data stays personal and the controller's duties do not go away. What changes is how much there is left to lose. </Warning> # Research Source: https://veriqa.app/docs/research/overview Directions we are working on: which Veriqa machinery they build on today and where their limits are. This section holds the directions we are working on. Every article answers the same questions: which problem the direction solves, what in the product it already builds on today, what is not there yet and where its limits are. <Note> An article in this section describes research, not a shipped capability. What works out will appear in releases as it is developed; if a direction does not hold up, the article itself will say so. Shipped capabilities are described in the rest of the documentation, channels in the [supported-channels catalog](/docs/guides/supported-channels). </Note> ## What is here <CardGroup> <Card title="Smart TV and keyboard-free surfaces" icon="tv" href="/docs/research/smart-tv"> Signing in where there is nothing to type on: a television, a kiosk, a three-button device. </Card> <Card title="Place and identity" icon="location-dot" href="/docs/research/place-and-identity"> A presence factor: a QR code that exists only inside the room. </Card> <Card title="Approving AI-agent actions" icon="robot" href="/docs/research/agent-approvals"> Human-in-the-loop on a surface the agent cannot reach. </Card> <Card title="Approvals in Claude Code" icon="terminal" href="/docs/research/claude-code-approvals"> A sample plugin: agent actions and costly runs wait for a person in the messenger. </Card> <Card title="Stronger sign-in with a chain of channels" icon="link" href="/docs/research/auth-chain"> One transaction confirmed in several trusted channels. </Card> <Card title="Resilience to a personal-data leak" icon="shield-halved" href="/docs/research/leak-resilience"> The link between a person and the way to reach them — the most toxic thing a breach can carry. </Card> <Card title="Honest voting on any website" icon="check-to-slot" href="/docs/research/honest-voting"> A vote confirmed in the voter's messenger and a result that can be checked. </Card> </CardGroup> ## How this differs from the "What's next" pages on the site Most of these directions also have a page on the site, and those answer **"why does this matter"**: which problem it solves, for whom, and what it is worth. The articles here answer a different question — **"what exists for it today"**: which existing machinery the direction builds on and what the product does not have yet. # Place and identity Source: https://veriqa.app/docs/research/place-and-identity A presence factor: a QR code that exists only inside the room. What the idea demands of the channel model and where its limits are. Every familiar sign-in factor is tied to a person or to their device, and not one of them answers the question "is the person actually here?". Place answers it. Why this matters and for whom is covered on the [Place and identity](https://veriqa.app/whats-next/place-and-identity?lang=en) page. Here is what it demands of the product. ## What it builds on The idea needs no new protocol: a sign-in on the work computer gives identity, a QR code on site gives presence. Both steps are ordinary confirmation transactions, which Veriqa already knows how to run. Several surfaces were considered, and they are not equally hard: | Surface | What it gives | What it costs | | - | - | - | | A sheet printed on the office printer | place, plus a way to carry the QR code where there is no screen | consumables, a shared output tray, vendor-specific printing | | A screen in a shared area (the meeting-room TV) | a large QR code, readable from anywhere in the room | the hardware is already there, but content appears only the way the vendor allows | | A desk IP phone | place more precisely — a specific desk rather than a corridor | the screen is small: how much data fits into a readable QR code is a question to measure | | A dedicated mini-panel | no consumables, no shared tray, no vendor-specific printing | it is a hardware purchase | ## What does not exist today Neither a printer nor a screen is a Veriqa channel, and the reason is not a missing adapter. **The channel model has no category of a "place channel" at all**: every existing channel is tied to a person — Telegram, WhatsApp, other channels and Email answer "who", not "where". Such a category is a change to the channel model rather than one more adapter, and it is common to every surface in the table above. ## Where its limits are * **Against a colleague in the same room the place factor does not protect at all** — they are already inside. The factor is exactly as strong as the control over physical access. * **Printing adds risks of its own.** A sheet outlives the session, it can be forgotten in a shared tray, and a personalized onboarding sheet is a long-lived linking secret that a short lifetime does not cure. Without one-time tokens and the invalidation of the remaining QR codes on first use, such a scenario is not safe. * **A QR code on a permanent screen is visible to everyone in the room and to cameras.** It can therefore only ever be a marker of place and never a secret: short lifetime, rotation, and possession of it alone grants nothing. <Note> A separate upside unrelated to security: where policy forbids radio and Bluetooth and Wi-Fi are switched off, a whole class of competing proximity factors is unavailable — cross-device passkeys confirm device proximity precisely over BLE. A paper or on-screen QR code needs no radio at all. </Note> # Smart TV and keyboard-free surfaces Source: https://veriqa.app/docs/research/smart-tv Signing in and confirming where there is nothing to type on: how this differs from device flow and what Veriqa already has for it. A television, a kiosk, a three-button device: start the sign-in where typing is awkward and confirm it in a messenger on the phone. Why this matters and for whom is covered on the [Smart TV and keyboard-free surfaces](https://veriqa.app/whats-next/smart-tv?lang=en) page. Here is what the product has for it today. ## Relationship to OAuth 2.0 Device Flow The first question people ask is whether this restates RFC 8628. It does not — the standard and Veriqa answer different questions and live on different layers. | | RFC 8628 | Veriqa | | - | - | - | | The question it answers | how a device without a browser obtains a token | **how** the user proves they are who they claim | | What it defines | `device_code`, `user_code`, `verification_uri`, token-endpoint polling | the confirmation method — possession of a trusted channel | | On the authentication method | says nothing: the `verification_uri` may hold anything, a password included | that is its entire subject | The overlap is only in the shape of the interaction — "start where typing is awkward, confirm on the phone". There is no protocol duplication: a browser sign-in through Veriqa lives entirely inside `authorization_code`, and the server-to-server entry point ends in an exchange of its own on the token endpoint. ## What it adds compared with device flow * **It collapses the verification step.** In device flow the second device goes through another full sign-in: open the address, type the code, authenticate. Here it is one tap in a messenger where the session already exists. Strictly speaking this is not an improvement on the standard but a method dropped into the hole the standard deliberately left empty. * **It targets code phishing.** A known weakness of device flow: the attacker starts the flow on their side and talks the victim into entering their code — the standard does nothing to bind the initiator to the confirming party. On a browser sign-in Veriqa shows the initiator's context at the moment of confirmation, and that addresses precisely this vector. * **It links a channel in the same step.** Outside device flow's scope entirely: the standard ends at issuing a token. <Warning> Showing the initiator's context is **not protection against relay or MITM**. The measure is probabilistic and works through the user's attention. The reinforcement is narrow; plain device flow does not have even that. </Warning> ## What exists today **A browser or a webview on the device.** If the platform offers a webview, the ordinary sign-in through `/connect/authorize` works: a page with a QR code, confirmation in a messenger, and the application receives OIDC tokens. **The server-to-server confirmation entry point — no browser needed.** The application calls `POST /api/transaction/confirmation`, authenticating with client credentials and naming a declared action type (`action_type`), and receives a `channel_entry` in the response: the address for the user (`url`) — the channel's deep link or the channel-selection page — and the same address as a ready-made QR image (`qr`, a PNG data URI) with the moment it stops being valid (`valid_until`). An application on any TV SDK can show that image; the device needs no browser for it. The user scans the QR code with their phone and confirms in the messenger. Send `include_channel_entries: true` and the response also carries `channel_entries` — a deep link with its QR code for every available channel — so the screen can offer a choice of messengers. The QR image carries the product attribution: a "Powered by Veriqa" strip under the code, below its quiet zone, so the picture is taller than it is wide — leave room for the strip rather than fitting the image into a square frame. The strip is part of what the software renders under its license: cropping it off is technically possible, and whether you may do so is a licensing matter, not a setting. The application reads the outcome with `GET /api/transaction/{id}/result`: `confirmed`, `declined`, `expired` or `failed`, and `pending` while there is no answer yet. If expected identities (`expected_identities`) were passed at creation, the result also tells whether the confirming person matched the expected one (`matched_type`). **Signing in without a browser, when the application has a backend.** Once the outcome is `confirmed`, the application's server exchanges the transaction for the `id_token` of the person who confirmed: `POST /connect/token` with `grant_type=urn:veriqa:params:oauth:grant-type:confirmation`, the `transaction_id` and a `scope` that contains `openid`. The server keeps the `sub` from the token next to its own account and opens its own session on the TV. The client needs `AllowClientCredentials`, `AllowConfirmationTokenGrant` and `openid` in `AllowedScopes`; the exchange and its rules are described in [Linking from your server](/docs/scenarios/channel-linking#linking-from-your-server-without-a-sign-in). How this differs from a browser sign-in: * **No `refresh_token`.** A transaction is redeemed once; the long-lived session on the TV is the application's own. * **It is a confirmation, not a sign-in.** It needs a declared `action_type` with its text, and with the grant enabled the application learns the confirming person's channel identity in **every** confirmation of that client. A separate client for the TV scenario is worth considering. * **The initiator's context is what your backend writes.** The request comes from your server, not from the TV, and the QR is a bearer entry: whoever opens it first confirms, and the `sub` is theirs. Put the device and the place into the action type's text through slots your backend fills. <Note> Client credentials are the application's secret. Call the server-to-server entry point from your own server, not from code on the device: a secret shipped inside a TV application can be extracted. </Note> ## What does not exist today * **A path for a device with no backend of its own.** The client secret cannot live on the device, so a TV application without a server has no way to obtain tokens yet. A full implementation of Device Authorization Grant (RFC 8628) is in progress. * **There are no native SDKs for TV platforms**: the server-to-server entry point and the exchange are plain HTTP requests. # Action confirmation Source: https://veriqa.app/docs/samples/confirmation Samples in which a backend asks a person to confirm an action in a messenger: a plain confirmation, step-up before a destructive action and approval of an AI agent's tool call. Here the backend, not the browser, starts the transaction: it asks a person to confirm an action in a messenger and acts on the outcome. There is no sign-in and no browser redirect. The three samples live under `samples/dotnet/inproc/` in the repository ([veriqa.app/source](https://veriqa.app/source)) and are MIT-licensed. | Sample | The action | What it adds | Address | | - | - | - | - | | `confirmation` | "Approve a payment of 42.00 EUR to ACME Ltd?" | The bare mechanism: create, show the channel entry, wait, read the outcome | `https://localhost:7310` | | `step-up` | Deleting a project, which cannot be undone | Only the **owner of the account** may confirm: `expected_identities` and `matched_type` | `https://localhost:7330` | | `agent-approval` | A support agent's `refund_payment` tool call | Human-in-the-loop: the tool runs only after a person approves the call | `https://localhost:7340` | ## How each of them works Every sample is one process in two roles: the Veriqa issuer (embedded) and the backend that calls it **only over the public HTTP API**. Point the backend at a standalone Veriqa and it works the same. 1. `POST /connect/token` with `grant_type=client_credentials`. 2. `POST /api/transaction/confirmation` with the declared `action_type` and its `slot_values`. 3. The page shows `channel_entry.qr` — the QR image the API returns — for the user to scan with their phone. 4. The backend polls `GET /api/transaction/{id}/result`. 5. On `confirmed` it may exchange the transaction once for the `id_token` of the person who confirmed (`grant_type=urn:veriqa:params:oauth:grant-type:confirmation`). The full contract — fields, errors, the exchange — is in the [confirmation API reference](/docs/reference/confirmation-api). ## The configuration that makes it work The backend's client entry in `appsettings.json` carries: * `AllowClientCredentials: true` — the backend may get a token for the API (needs a `ClientSecret`); * `AllowConfirmationTokenGrant: true` and `openid` in `AllowedScopes` — step 5; * `MessageTemplates:<action type>` — the declaration of the action: `Contract:Slots` and `Templates`. It belongs in the **client entry**; an action type declared only at the host level is refused with `action_type_unknown`; * for `step-up` also `IdentityMatchComparableTypes`, which declares the identity compared with the person who confirmed. Without it the request is refused with `candidate_type_undeclared`. The host section sets `Veriqa:Channels:OutcomeNotice:DisplayIntent` to `NewMessage`: the receipt arrives as a message of its own and the question with the wording of the action stays in the conversation. See `OutcomeNotice` in [Configuration](/docs/reference/configuration). ## Run a sample Each sample needs a trusted local HTTPS certificate (`dotnet dev-certs https --trust`) and a Telegram bot token from [@BotFather](https://t.me/BotFather). Pass the token through user secrets, not a committed file: ```bash theme={null} dotnet user-secrets --project samples/dotnet/inproc/confirmation set "Veriqa:Channels:Telegram:BotToken" "<token>" dotnet user-secrets --project samples/dotnet/inproc/confirmation set "Veriqa:Channels:Telegram:Enabled" "true" dotnet run --project samples/dotnet/inproc/confirmation ``` For `step-up` and `agent-approval` replace the project path. In Development the bot runs in polling mode, so no public URL is needed. Without an enabled channel, creating a confirmation fails with `503 channel_display_failed`. What to try in each: * **confirmation** — click **Ask for approval**, confirm in the bot: the page shows `Outcome: confirmed` and the claims of the `id_token`. * **step-up** — delete a project: the first person to confirm becomes the owner. Delete another one from a different Telegram account: the confirmation succeeds, but the page shows *Not deleted — confirmed, but not by the owner*. * **agent-approval** — click **Hand the ticket to the agent**: the agent stops at `refund_payment` until you approve. Decline on a second run: the refund is not executed and the agent tells the customer a colleague will contact them. The agent follows a fixed plan, so no model or API key is needed. ## See also * [Second factor, step-up and action approvals](/docs/scenarios/step-up) * [Approving AI-agent actions](/docs/research/agent-approvals) * [Video: step-up on a desktop](/docs/videos/desktop-step-up) # Nova Stream — streaming on a TV Source: https://veriqa.app/docs/samples/showcase/streaming-tv A showcase sample that imitates a streaming service on a television: sign-in by QR code from the sofa, the viewer's card from channel claims, and step-up when buying a title. Nova Stream is a runnable emulator of a **product**, not of one mechanism. The scenario samples each show a single thing — a sign-in, a confirmation, a step-up; this one strings them together the way a viewer encounters them on a television: a sign-in, the person's data, recognition on the next visit and a confirmed purchase. The sample lives in the repository ([veriqa.app/source](https://veriqa.app/source)) under `samples/dotnet/showcase/streaming-tv` and listens on `https://localhost:7350`. Its `README.md` is the detailed setup guide; this page is the overview. ## What the viewer sees <Steps> <Step title="Sign-in by QR code from the sofa"> The TV redirects to the Veriqa sign-in page, which shows a QR code. The viewer scans it with their phone camera and confirms in the messenger — nothing is typed with the remote. </Step> <Step title="A welcome card from the channel's claims"> The card on screen is built from what the channel provides: display name, first and last name, picture, `@username` and a masked user id (Telegram), a masked phone (WhatsApp), an address (Email) — Nova Stream asks for the contacts at sign-in. Identifiers are masked on the server; the full value never reaches the browser. </Step> <Step title="A date of birth and a favorite genre inside the sign-in"> The `Veriqa:ScopesClaims` catalog declares both, so Veriqa asks for them on its own form inside the sign-in, and the collected claims store remembers the answer. 18+ titles are closed to a viewer under age, and titles of the favorite genre come first. See [Progressive claim enrichment](/docs/concepts/claim-enrichment). </Step> <Step title="Recognition of a returning viewer"> A first-time viewer is welcomed by name; a viewer who has been here before lands straight in the catalog. </Step> <Step title="Step-up when buying a title"> Buying a title shows a QR code and is confirmed in the channel **by the viewer who asked**. A confirmation from anybody else buys nothing. </Step> </Steps> The two flows leave different receipts in the chat. A sign-in's question is replaced by "Sign-in to Nova Stream confirmed ✅" (`ReplacePrompt`); a purchase keeps its question and gets the receipt as a message of its own (`NewMessage`, set in the backend client's entry), so what was confirmed stays readable. See `OutcomeNotice` in [Configuration](/docs/reference/configuration). ### The interface on a television The interface belongs to the fictional service: a ten-foot layout, remote-control navigation and no interaction that needs a pointer. | Key | What it does | | - | - | | arrows | move the focus | | `Enter` | select | | `Backspace`, Tizen `10009`, webOS `461` | close the purchase window | | `F` (desktop) | full screen | The sign-in page and the confirmation window are Veriqa's own screens, shown as they are: the sample names its brand beside the Veriqa mark and adapts the arrangement for a big screen through `AuthPageDesign`: * the television shows the QR codes of all enabled channels side by side — the [channel layout](/docs/guides/customization#channel-layout) is `Grid`, because nobody walks up to a TV to switch a tab; * the "Open in …" buttons appear on a phone only — the [visibility of the channel buttons](/docs/guides/customization#channel-button-visibility) is `MobileOnly`; a phone that opens the page gets the buttons instead of the QR; * the QR of the purchase window is drawn at 12 px per module instead of the default 6, so it scans off the screen from across the room; * a stylesheet enlarges the card on screens 1600 CSS pixels wide or wider. **A set of channels per browser language.** The sign-in client has two `ui_config` records that differ in `Channels`; the sample names one of them in the authorize request by the first language of `Accept-Language`. A record narrows what the host has enabled — it is presentation, not a restriction. ### TV browsers Everything the page ships targets **ES2018** and the CSS of Chromium 68 — the engine of Tizen 5.5 and webOS 5 — with fallbacks for the Chromium 53 of webOS 4.x where a layout depends on it. Newer syntax such as `?.` or `??` is a parse error there: the script does not load at all. Nothing is loaded from outside the origin. ## How it is built One process plays three roles; splitting them into separate hosts changes nothing but the addresses. | Role | What it is | | - | - | | Embedded issuer | Veriqa inside the process: `AddVeriqaAuthServer` → `UseVeriqaAuthServer` → `MapVeriqaAuthServer` | | Browser-side OIDC client | the standard `AddOpenIdConnect` handler, pointed at this very process | | Backend that creates confirmations | `VeriqaConfirmationClient`, calling the [server-to-server confirmation API](/docs/reference/confirmation-api) over HTTP | The purchase creates a confirmation that names **one** expected identity — the identity from the channel the current session came through (`expected_identities`). The title is bought only when the outcome is `confirmed` and `matched_type` is the expected type; another type, `declined`, `expired` or `failed` leave the library unchanged. The server remembers which title a transaction pays for, so no browser can buy one title by confirming another. ## Running it * .NET SDK 10.0+ and a trusted development certificate (`dotnet dev-certs https --trust`) — the application calls itself over HTTPS. * **Enable at least one channel and give it credentials.** Channels ship disabled with empty credentials; a Telegram bot token is the shortest path: ```bash theme={null} dotnet user-secrets --project samples/dotnet/showcase/streaming-tv set "Veriqa:Channels:Telegram:BotToken" "<token>" dotnet user-secrets --project samples/dotnet/showcase/streaming-tv set "Veriqa:Channels:Telegram:Enabled" "true" dotnet run --project samples/dotnet/showcase/streaming-tv ``` Open `https://localhost:7350`. WhatsApp and Email are switched on the same way, through their sections of `appsettings.json`. A sign-in page that offers no way in means no channel is enabled. <Note> In `Development` the sample switches WhatsApp and Email on with placeholder values, only to show what a multi-channel sign-in page looks like. Neither can carry a sign-in through — Telegram is the channel that works. </Note> **On a real TV.** A television cannot reach `localhost`: put the sample behind an HTTP tunnel. Nothing in the code changes — the issuer, the redirect URI, the authority and the channels' public addresses are set to the public host through environment variables, all of them together, and Telegram switches to `Webhook` mode. The exact list is in the sample's README; see also [Behind a reverse proxy](/docs/guides/reverse-proxy). <Tip> A Telegram bot delivers its updates to one webhook address, so with one bot token only one of the two showcase samples can take sign-ins at a time. </Tip> ## Videos <CardGroup> <Card title="Sign-in on a TV" icon="tv" href="/docs/videos/tv-sign-in"> Sign-in by QR code from the sofa on a real TV, with no keyboard and no typing. </Card> <Card title="Step-up on a TV" icon="cart-shopping" href="/docs/videos/tv-step-up"> Buying a title, confirmed in the messenger by the signed-in viewer. </Card> </CardGroup> ## See also * [Samples](/docs/samples#showcase-samples) * [Sign-in surfaces](/docs/scenarios/surfaces) * [Smart TV and keyboard-free surfaces](/docs/research/smart-tv) * [Second factor, step-up and action approvals](/docs/scenarios/step-up) # Helio — customer support Source: https://veriqa.app/docs/samples/showcase/support-desk A showcase sample that imitates the support section of a website: a public page, sign-in, the customer's card and step-up before a ticket is closed or deleted. Helio is a runnable emulator of a whole product rather than of one mechanism: the support section of a fictional company's website. It strings together what an ordinary web product does — a page anyone can read, sign-in, the person's data, and a confirmation before an action that matters — so the flows can be seen end to end the way a customer encounters them. `samples/dotnet/showcase/support-desk` · `https://localhost:7360` <Note> The support page belongs to the fictional company. The sign-in page and the confirmation window are Veriqa's own screens, shown as they are: `AuthPageDesign` sets only the brand name, the logo, the primary color and shows the QR code only on a desktop. </Note> ## What the customer sees <Steps> <Step title="A public page"> The support section is readable by anyone; nobody is redirected on arrival. </Step> <Step title="Sign-in"> The visitor is sent to the Veriqa sign-in page and comes back signed in: with a QR code on a desktop, with the channel button on a phone. The sign-in asks for no contact. </Step> <Step title="The customer's card"> The card is built from the claims the channel provides: display name, first and last name, picture, `@username` and a masked user id (Telegram). A phone or an address appears only once the customer has confirmed it. Next to it are the customer's tickets. </Step> <Step title="A contact when a feature needs it"> "Call me back" and "Answer by e-mail" open once the customer confirms a phone number or an email address — each by one more authorize request. The request adds the scope and names the claim as essential in the `claims` parameter, and the application accepts the value only with its `*_verified` mark. See [Progressive claim enrichment](/docs/concepts/claim-enrichment). </Step> <Step title="Step-up"> Closing or deleting a ticket is confirmed in the channel, **by that person**. The dialog on the page only guards against a misclick; the question in the channel is the one that decides. </Step> </Steps> The two flows leave different receipts in the chat. A sign-in's question is replaced by "Sign-in to Helio Support confirmed ✅" (`ReplacePrompt`); a ticket action keeps its question and gets the receipt as a message of its own (`NewMessage`, set in the backend client's entry), so what was confirmed stays readable. See `OutcomeNotice` in [Configuration](/docs/reference/configuration). ## How it is built One process plays three roles: | Role | What it is | | - | - | | Embedded Veriqa issuer | `AddVeriqaAuthServer` → `UseVeriqaAuthServer` → `MapVeriqaAuthServer` | | Browser-side OIDC client | the standard `AddOpenIdConnect` handler, pointed at this very process | | Backend that creates confirmations | `VeriqaConfirmationClient`, calling the [server-to-server confirmation API](/docs/reference/confirmation-api) over HTTP | **Step-up addressed to the signed-in person.** Closing and deleting a ticket are two declared action types, `resolve-ticket` and `delete-ticket`, each with its own wording. Every confirmation names one expected identity — the identity from the channel the current session came through (`expected_identities`) — and the operation is applied only when `matched_type` agrees: a confirmation from another account leaves the ticket untouched, as do a decline and an expiry. The server remembers which ticket a transaction acts on, so no browser can delete one ticket by confirming another. **Identifiers stay on the server.** The full channel user id addresses the step-up and is never sent to the browser: the user id and the phone number are masked before they are put into a response. **No database.** The tickets are synthesized from the `sub` claim, so the same person sees the same list on every visit. Only the tickets the customer has closed or deleted are kept in memory; a restart forgets them. <Note> This step-up goes through the server-to-server confirmation API. The other route — a repeat authorize and an `auth_time` check in the browser — is described in [Second factor, step-up and action approvals](/docs/scenarios/step-up). </Note> ## Running it Prerequisites: * .NET SDK 10.0+; * a trusted development certificate — the application calls **itself** over HTTPS: `dotnet dev-certs https --trust`; * credentials for at least one channel. A Telegram bot token from [@BotFather](https://t.me/BotFather) is the shortest path. Channels ship **disabled with empty credentials**: a channel switched on without its credentials stops the application from starting. Switch on the ones you have and start the sample: ```bash theme={null} dotnet user-secrets --project samples/dotnet/showcase/support-desk set "Veriqa:Channels:Telegram:BotToken" "<token>" dotnet user-secrets --project samples/dotnet/showcase/support-desk set "Veriqa:Channels:Telegram:Enabled" "true" dotnet run --project samples/dotnet/showcase/support-desk ``` Open `https://localhost:7360`. The sample wires the Telegram, WhatsApp and Email adapters; WhatsApp and Email are switched on the same way, through their sections `Veriqa:Channels:WhatsApp:*` and `Veriqa:Channels:Email:*` with `Enabled` set to `true`. The sign-in page offers exactly the channels that are on — an empty sign-in page means none is enabled. Credentials for each channel are described in [Channel setup](/docs/guides/channels). <Tip> A Telegram bot delivers its updates to one webhook address, so with one bot token only one of the two showcase samples can take sign-ins at a time. </Tip> The sample also runs behind an HTTP tunnel, with Telegram switched to `Webhook` mode. The variables to set, the API of the page and the details of the step-up are in the sample's `README.md` in the repository ([veriqa.app/source](https://veriqa.app/source), path `samples/dotnet/showcase/support-desk`) — it remains the detailed setup guide. ## Videos <CardGroup> <Card title="Sign-in on a desktop" icon="desktop" href="/docs/videos/desktop-sign-in"> Sign-in by QR and the customer card built from the channel claims. </Card> <Card title="Sign-in on a phone" icon="mobile" href="/docs/videos/mobile-sign-in"> No QR code — the channel button opens the messenger on the same device. </Card> <Card title="Step-up on a desktop" icon="desktop" href="/docs/videos/desktop-step-up"> Deleting a ticket takes a confirmation in the messenger. </Card> <Card title="Step-up on a phone" icon="mobile" href="/docs/videos/mobile-step-up"> Closing a ticket is confirmed in Telegram on the same device. </Card> </CardGroup> ## See also * [Showcase samples](/docs/samples#showcase-samples) * [Second factor, step-up and action approvals](/docs/scenarios/step-up) * [Server-to-server confirmation API](/docs/reference/confirmation-api) * [Sign-in and channel linking in one action](/docs/scenarios/channel-linking) * [Channel setup](/docs/guides/channels) # Sign-in on a website via a messenger Source: https://veriqa.app/docs/samples/sign-in Samples of website sign-in through a messenger: the issuer with Veriqa embedded, a stock OIDC client, the same sign-in under .NET Aspire and the client code for an external issuer. A website signs a person in through a messenger the same way it would through any OpenID Connect provider: it redirects to the issuer, the person confirms in the messenger, and the browser comes back with tokens. The samples below show both sides of that sign-in. Everything lives under `samples/` in the repository ([veriqa.app/source](https://veriqa.app/source)) and is MIT-licensed. <Note> Veriqa lives on the **issuer** side. The website itself stays a plain OIDC relying party: the client sample has no Veriqa package and would look the same against any conformant provider. </Note> ## The samples | Path | Side | What it shows | Form | | - | - | - | - | | `dotnet/inproc/login` | issuer | Veriqa embedded in the application (`AddVeriqaAuthServer` / `MapVeriqaAuthServer`); the issuer runs inside the process | runnable · `https://localhost:7300` | | `dotnet/inproc/login-client` | client | A stock ASP.NET Core relying party (`AddCookie` + `AddOpenIdConnect`, Authorization Code + PKCE) | runnable · `https://localhost:7020` | | `dotnet/aspire/login` | issuer | The same embedded issuer under .NET Aspire: the transaction store is an orchestrated resource (needs Docker) | runnable · `https://localhost:7320` | | `dotnet/remote/login` | client | A client of an external issuer — self-hosted or Veriqa Cloud | code to copy | | `java/Application.java` | client | The same client on Spring Security OAuth2 Client | code to copy | | `node/index.ts` | client | The same client on `openid-client` + Express | code to copy | | `python/main.py` | client | The same client on Authlib + FastAPI | code to copy | ## Run the pair The issuer starts first — otherwise the client fails its discovery call. <Steps> <Step title="Enable a channel on the issuer"> The issuer ships with every channel `"Enabled": false`, and with none enabled the sign-in page returns `no_channels_available`. Enable a channel in `samples/dotnet/inproc/login/appsettings.Development.json` and give it credentials — a bot token is the quickest route, see [Channel setup](/docs/guides/channels). </Step> <Step title="Start the issuer"> ```bash theme={null} dotnet run --project samples/dotnet/inproc/login ``` Its root route returns `Hello!` and nothing more: the issuer has no page for a signed-in user; the sign-in starts and ends at the client. </Step> <Step title="Start the client and sign in"> ```bash theme={null} dotnet run --project samples/dotnet/inproc/login-client ``` Open `https://localhost:7020/`. An anonymous visitor goes straight to the Veriqa sign-in page; after the confirmation in the messenger the page shows `Hello, {name}!`. </Step> </Steps> Both projects need a trusted local HTTPS certificate (`dotnet dev-certs https --trust`). Use the `https` address of the client: a flow started over plain HTTP sends a `redirect_uri` the issuer does not know. ## Why the pair needs no configuration The issuer already registers the second project as its client, so three values agree out of the box: | Client | Issuer (`Veriqa:OpenIddict:Clients[]`) | | - | - | | `Oidc:Authority` = `https://localhost:7300` | the issuer's own address | | `Oidc:ClientId` = `my-app` | `ClientId` | | `CallbackPath` = `/signin-oidc` on port 7020 | an `AllowedRedirectUris` entry | `redirect_uri` is compared literally, without wildcards: change the client's port and change `AllowedRedirectUris` with it. `my-app` has no `ClientSecret`, so it is a **public** client and PKCE (S256) is required of it. ## What else the client shows * **Cancel** on the Veriqa page returns `error=access_denied`; the client turns it into a plain "sign-in canceled" page through `AccessDeniedPath`. * Any other failed return goes through `OnRemoteFailure` to a "sign-in failed" page; the error text goes to the log, not to the user. * `GET /signout` drops only the local cookie. Veriqa has no end-session endpoint, so there is no federated sign-out — see [Known limitations](/docs/reference/limitations). ## See also * [.NET quickstart](/docs/quickstart/dotnet) — the same wiring, step by step * [How the auth flow works](/docs/concepts/auth-flow) * [Passwordless sign-in and registration](/docs/scenarios/passwordless-login) * [Video: sign-in on a website](/docs/videos/web-sign-in) # Account recovery Source: https://veriqa.app/docs/scenarios/account-recovery "Forgot password" stops being a quest: a linked trusted channel becomes the recovery path, with no chain of emails and codes. Classic account recovery is a chain: an email with a link, a click, a new-password form, sometimes an SMS code on top. Every link breaks — the email lands in spam, the mailbox is unreachable, the number has changed. When a user has a **linked trusted channel**, recovery becomes the same action as signing in: a confirmation in a messenger where they are already authenticated. ## How it works Veriqa has no separate "recovery mode", and none is needed. The user goes through an ordinary sign-in via the linked channel, you recognize them by `sub` (or by the stored `channel_type` + `channel_user_id` pair) and grant access to the account. What follows is your decision: let them straight in, offer to set a new password, or require a second confirmation in another channel if the operation warrants it — see [step-up](/docs/scenarios/step-up). ## What has to be in place first The channel must be linked **before** access needs recovering. The convenient moment is the sign-in itself: one action gives you both authentication and the link — see [sign-in and channel linking](/docs/scenarios/channel-linking). ## What Veriqa does not do * **it does not keep your accounts** and has no concept of "recovery" — it confirms an identity in a channel, the rest is your logic; * **it does not store passwords** and takes no part in changing them; * **it does not decide for you** whether one confirmation is enough: that policy is yours. <Warning> **Recovery is exactly as trustworthy as the channel behind it.** The link lives on your side, and so does the decision that this channel suffices to hand an account back. For high-privilege accounts, requiring confirmation in two different channels is the sounder default. </Warning> # Sign-in and channel linking in one action Source: https://veriqa.app/docs/scenarios/channel-linking One action, two results: the user signs in and the product gains a live notification channel. What to send, and what to store on your side. A single user action produces **two results**: they sign in, and your product gains a **live channel** — Telegram, WhatsApp, [another messenger](/docs/guides/supported-channels) or email, whichever they confirmed in. From then on you can send notifications, approvals and service messages without asking for a contact separately. ## What you send An ordinary authorize request with the `channel` scope: ```http theme={null} GET /connect/authorize ?client_id=my-app &response_type=code &scope=openid%20profile%20channel &redirect_uri=https://app.example.com/callback &state=… &code_challenge=… &code_challenge_method=S256 ``` ## What you get After exchanging the code for tokens, two extra claims: | Claim | Meaning | | - | - | | `channel_type` | The channel type: `telegram`, `whatsapp`, `max`, `email` | | `channel_user_id` | The user's identifier inside that channel | **Without the `channel` scope there are no typed claims.** They reach neither the `access_token` nor the `id_token`, and therefore never appear in the `/connect/userinfo` response — it mirrors the access token. <Warning> The gate closes the individual claims but **not the value**. With the default identity resolver `sub` is composed as `{channel_type}:{channel_user_id}`, so an application that splits the string on the colon gets both parts without requesting the scope. Treat the gate as a way **not to handle** channel identifiers where you do not need them, rather than as a way to hide them. </Warning> <Note> The gate applies in **every delivery mode, the embedded one included**. Even when Veriqa is linked as a library into your own process, the application receives the identity through the normal authorize → code → token exchange rather than as a ready-made principal. There is no "internal" path that hands out channel claims bypassing the scope. </Note> ## How to link them `sub` is the stable subject identifier; the `channel_type` + `channel_user_id` pair is the user's address in a specific messenger. The link lives **on your side**: Veriqa does not keep your application's accounts and does not map them to channels. The practical consequence: `sub` is your account key. The off-the-shelf CMSes do the same — Drupal and TYPO3 store it as the external identity name. ## When the account already exists The scenario doubles as "add a channel to an existing account": a user who signed in the usual way goes through authorize with the `channel` scope, and you store the returned pair next to your account. There is no separate API for this — it is the same request. ## Linking from your server, without a sign-in Sometimes there is no browser redirect to hang the link on: your server wants a channel for an account on its own — for example, a signed-in user asks your support bot to connect a messenger. That goes through the server-to-server confirmation entry followed by a token exchange. **What the integrator enables** for the client: `AllowClientCredentials`, `AllowConfirmationTokenGrant` and `openid` in `AllowedScopes` (plus `channel` if you want the typed claims). The permission is the integrator's: no parameter of your request widens what is disclosed, and the claims never go beyond `AllowedScopes`. 1. **Create a confirmation transaction** with `POST /api/transaction/confirmation`, **without** `expected_identities` — you do not know yet which channel account will confirm; that is exactly what you want to learn. Show the user the `channel_entry` from the response. 2. **Wait for the outcome** with `GET /api/transaction/{id}/result` until it is `confirmed`. With no expected identities `matched_type` is `null` — that is not a refusal, and the token is issued all the same. 3. **Exchange the transaction for a token:** ```http theme={null} POST /connect/token Content-Type: application/x-www-form-urlencoded Authorization: Basic … grant_type=urn:veriqa:params:oauth:grant-type:confirmation &transaction_id=… &scope=openid%20channel ``` You get an ordinary token response: `access_token`, `token_type`, `expires_in` and `id_token`, with no `refresh_token`. `scope` follows [RFC 6749 §5.1](https://datatracker.ietf.org/doc/html/rfc6749#section-5.1): it is returned only when the granted scopes differ from the requested ones. The exchange grants exactly what you requested, so the member is absent — do not require it. 4. **Store `sub`** from the `id_token` next to your account. The rules of the exchange: * `scope` must contain `openid`; `channel` adds `channel_type` and `channel_user_id`; anything else stays within `AllowedScopes`. `offline_access` is refused with `invalid_scope`, as is a `scope` without `openid`. A missing `transaction_id` is `invalid_request`. * **A transaction is redeemed once.** A second exchange gets `invalid_grant` — the same error with the same description as an unknown identifier, another client's transaction, a transaction not yet completed or one already removed after `CompletedRetentionSeconds`. The responses do not tell these reasons apart on purpose, so exchange right after `confirmed`. Of two simultaneous exchanges, exactly one gets the token. * Once applies to the redemption, not to reading the claims: the `access_token` of the same response carries the same claims and returns them from `/connect/userinfo` until it expires. * **The link key is `sub` as a whole.** With the default identity resolver it is composed as `{channel_type}:{channel_user_id}`, but a host with its own identity resolver may compose it differently — do not parse the string; request the `channel` scope when you need the pair. <Warning> **What the application learns, and who warns the user.** With the grant enabled, your application receives the confirming person's channel identity in **every** confirmation of that client, not only where the task needs it. Veriqa asks the user no separate "share my data" question: the warning that data is being handed over, and the context of the request, live in the wording of the integrator's `action_type` and in the slots your backend fills. Veriqa does not check that every action type of such a client carries that warning — it is the integrator's responsibility. The entry is a bearer one: whoever opens it first confirms, and `sub` belongs to them — which is why the context of the request belongs in the wording. </Warning> The context of the request is assembled from `string` slots of the action type's message contract; each such slot must declare its `max_length`, and the ceiling is 128 characters. The integrator writes the wording, and your backend fills the slots (`slot_values`) at creation with what it sees of the device — someone who forwards the QR cannot forge those values, because they do not own your backend. For example, a template: ```text theme={null} Link {service} to this messenger account? {service} will receive your account identifier. Requested from {device}, {location}. ``` Here `service`, `device` and `location` are slots your backend fills. The server fills `{app}` of a confirmation itself, with the transaction's application identifier (`client_id`) rather than the client's `DisplayName` — so a human-readable application name reaches the wording only through a slot of your own. *The example is synthetic: these slot names and this wording are not in the shipped configuration.* How to declare the contract and the template, and the full request and response, are in [Server-to-server confirmation API](/docs/reference/confirmation-api). ## Two bots: handing the user over to your bot The sign-in is handled by the bot Veriqa runs, while your own bot runs the business scenarios. After the sign-in the first bot shows a link button into the second, and your bot has to learn **who** tapped it. The address of that button can carry a **link handle** — a single-use value your server exchanges for the identity of the user who has just signed in. Two bots is one of several ways to use a bot you already run together with Veriqa — the comparison is in [You already have a bot](/docs/guides/existing-bot). 1. **Put `{handle}` into the address of the button** — `RedirectUrl` or the `Action` of a link item of the menu ([Menu and Redirect](/docs/reference/configuration#menu-and-redirect-a-message-with-buttons)): ```json theme={null} { "Veriqa": { "CompletionBehavior": { "ActionByChannel": { "telegram": "Redirect" }, "RedirectUrl": "https://t.me/shop_bot?start={handle}" } } } ``` *The example is synthetic: `shop_bot` is not a real bot.* The handle is 22 characters `[0-9A-Za-z]`; the rest of the platform's budget for the start parameter (64 characters in Telegram) is yours. 2. **Your bot receives the handle** when the user taps the button — in Telegram, as `/start <handle>`. 3. **Your server exchanges it**, with the client credentials token of the application the user signed in to ([how to get the token](/docs/reference/confirmation-api#2-get-a-token); the client needs `AllowClientCredentials`): ```http theme={null} POST /api/link-handle/redeem Authorization: Bearer <access_token> Content-Type: application/json { "handle": "…" } ``` ```json theme={null} { "sub": "telegram:123456789", "channel_type": "telegram", "channel_user_id": "123456789" } ``` `channel_type` and `channel_user_id` are returned only to a client whose `AllowedScopes` contain `channel`; otherwise the response carries `sub` alone. `sub` is the one this application receives in its tokens. 4. **Store the link on your side** — "the user of my bot ↔ `sub`". Veriqa keeps the handle only until it is exchanged or expires, and keeps no link. <Warning> **Check who tapped the button.** A button can be forwarded, and whoever taps it brings the handle to your bot. Where the platform gives a person one identifier for every bot — Telegram, for example — compare the sender of the message (`from.id`) with `channel_user_id` from the response and refuse a mismatch. Veriqa does not make this comparison for you: the endpoint takes the handle only. The comparison needs the `channel` scope on the client. Where each bot sees its own identifier for the same person, or the tenant has pseudonymization on (`channel_user_id` is then a pseudonym), there is nothing to compare with — the handle is a bearer link: the link goes to whoever taps the button first. </Warning> The rules of the exchange: * **A handle is exchanged once**, and only by the application the sign-in belongs to. Another application gets `404`, and the handle stays valid for its own. * **It is short-lived:** `Veriqa:TransactionEngine:CompletedRetentionSeconds` (600 seconds by default) from the moment the button is sent. The button stays in the chat: tapped later, it brings your bot a handle for which the endpoint returns `404` — ask the user to sign in again. * **`404` is one response** for a missing or empty `handle`, an unknown, expired or already exchanged one, and another application's — the reasons are not told apart on purpose. A body that is not JSON is `400`, a `Content-Type` other than JSON is `415`; no token is `401`; a token issued to a user is `403`. A `500` means the identity could not be read — that handle is spent too. * **The handle works before the browser finishes the sign-in** — and when the browser tab was closed and never does. The identity is confirmed by the user in the channel by then. * **Several replicas need a shared store.** Without the Redis [channel stores](/docs/guides/storage#channel-stores-multiple-replicas) a handle is found only by the replica that issued it. * **A button without a handle is fine** where you do not need it — in Telegram your bot sees the same user identifier the sign-in returned in `channel_user_id`, so an account already linked is recognized without an exchange. * The endpoint exists for sign-ins only; a confirmation your server creates is followed by no button. ### When the sign-in is pointless without your bot The sign-in completes when the user confirms it in the channel: the browser receives the `code` and returns to your application. The button into your bot arrives afterwards and does not affect the sign-in — a user who never taps it is signed in all the same, while your bot does not know them yet and cannot message them. If the move into your bot is mandatory for you, the gate belongs to your application: 1. **After the sign-in, check whether a link exists** for the `sub` you received. It appears when your server has exchanged the handle (step 3 above); where your bot sees the same identifier the sign-in returned, it appears when the bot has received the first message from that user. 2. **No link — show a "one step left" screen** with a link into your bot, and do not let the user go any further. The user is already signed in with you, so you can issue the link on that screen yourself: a single-use token of your own in the start parameter, which your bot matches to the session. It does not need the handle from Veriqa — that one exists only in the button in the chat and is short-lived. 3. **Grant access once the link has appeared** — on an event from your bot or by polling from the page. If there is no website and your product lives entirely in the bot, no separate gate is needed: the exchange of the handle is the sign-in to the bot, and a user who did not tap the button simply did not sign in. <Note> There is no setting today that makes Veriqa hold the sign-in until the handle is exchanged. We are considering one; until then your application is what makes the move mandatory. </Note> <Card title="One sign-in, two channels" icon="shield-halved" href="/docs/scenarios/step-up"> A sensitive operation can require confirmation in two different trusted channels. </Card> # Leads and feedback without CAPTCHA Source: https://veriqa.app/docs/scenarios/lead-capture A visitor verifies themselves through a messenger in seconds — the site gets a verified contact and a live channel instead of a dead address from a form. A feedback form collects whatever was typed into it: a mistyped address, a disposable mailbox, a made-up number. CAPTCHA filters out some bots, but it does nothing for the quality of the contact. Veriqa handles it differently: the visitor verifies themselves in a messenger within seconds, and the site receives a **verified contact and a live channel** — one you can actually write to later, not just store. ## Two ways, and they are different solutions <CardGroup> <Card title="Submission without sign-in" icon="file-signature" href="/docs/integrations/confirmed-form"> The user confirms the submission, but **no session is created** and no account is provisioned. Needs a small server-side handler next to the form. </Card> <Card title="Sign-in with channel linking" icon="link" href="/docs/scenarios/channel-linking"> The user signs in and you gain the channel along the way — if you have accounts and a member area. </Card> </CardGroup> <Warning> **No OIDC plugin for any CMS does the first one.** Plugins cover signing in to an account, while Contact Form 7, WPForms and Bitrix web forms are not tied to an account at all. "A member area" and "a confirmed submission" are two different solutions, not two settings of one. </Warning> ## What the site gets * **proof of confirmation** — who exactly (`sub`) confirmed the submission and in which channel (`channel_type`); * **a live channel** to reply through: a message in the messenger someone just confirmed in beats an address typed by hand; * **a bot filter cheaper than CAPTCHA**: some channels report that the sender is a bot, Veriqa returns that flag in the claims, and such submissions can be cut off **before** a record is created. <Note> This is a filter, not anti-fraud. It removes the stream of junk submissions; it does not replace a fraud-prevention system. </Note> ## What it takes The server-side handler is minimal: about 250 lines of PHP with no external dependencies. Step by step on [Confirmed form submission](/docs/integrations/confirmed-form). # Passwordless sign-in and registration Source: https://veriqa.app/docs/scenarios/passwordless-login The base scenario: a QR on screen, confirmation in a messenger, registration in the same action — and what your application has to do for it. The base Veriqa scenario: the user opens your application on a desktop, sees a QR code, scans it with their phone camera and confirms the sign-in in a messenger. There is no form to fill in and no password to create: **registration happens in the same action as the sign-in**. ## What your application does Nothing beyond ordinary OpenID Connect: ```http theme={null} GET /connect/authorize ?client_id=my-app &response_type=code &scope=openid%20profile &redirect_uri=https://app.example.com/callback &state=… &nonce=… &code_challenge=… &code_challenge_method=S256 ``` Then the standard code-for-tokens exchange. Veriqa needs no client SDK, widget or JS component: the sign-in page with the QR code is rendered by the server. ## What the user experiences 1. a QR code appears on screen — that is all the sign-in needs; 2. the user opens their phone camera, scans it and follows the link; 3. a familiar app opens — Telegram, WhatsApp or [another messenger](/docs/guides/supported-channels) (or an email, on the Email channel); 4. the user confirms with a button — no password, no SMS code; 5. your auth system issues the tokens and the sign-in is done. <Note> The first sign-in and every later one look identical to the user. The difference is on your side: if the `sub` is new to you, you provision an account; if it is not, you recognize the existing one. </Note> ## What arrives in the claims `sub` is the stable subject identifier — the way you recognize a returning user. With `scope=profile` a human-readable name and related fields come along; the exact set depends on the channel. <Warning> **Not every channel provides an email.** Telegram does not hand out addresses and never will, so the account is created without one. If an email is mandatory for you, use the Email channel or ask for the address in a separate step after the first sign-in. How the off-the-shelf CMSes cope with this is in [Veriqa in a CMS](/docs/integrations/overview). </Warning> ## What if the messenger is not on that device That is the point: the confirmation happens **on another device**, the phone. The messenger does not have to be installed where the user signs in from — which is exactly what separates this from the built-in messenger sign-in buttons, where the app and the messenger must share a machine. Same-device sign-in works too, but it is the fallback rather than the main path. ## Next <CardGroup> <Card title="Channel linking" icon="link" href="/docs/scenarios/channel-linking"> Get a live notification channel from the same action. </Card> <Card title="The full OIDC flow" icon="route" href="/docs/concepts/auth-flow"> What happens between authorize and token. </Card> </CardGroup> # Replacing SMS codes Source: https://veriqa.app/docs/scenarios/sms-replacement The same familiar flow, with no per-message charge and with full authentication instead of code delivery. An SMS code solves a narrow problem: deliver a string the user will retype into a form. Veriqa covers the same need differently — the user **confirms** the sign-in or the operation with a tap in a messenger, and your application receives a full authentication result rather than "the code matched". ## What changes for the user * no retyping a code between screens — one tap in an app that is already open; * nothing depends on SMS delivery: carrier queues, roaming, "the message will arrive within five minutes"; * no phone number to hand over — the identity is confirmed through the channel. ## What changes for you * **messages are not billed per message**, unlike SMS: at significant sign-in volumes that is direct savings; * instead of a home-grown "the code in the form matches the one we sent", you get standard OpenID Connect: tokens, claims, lifetimes, `auth_time`; * less personal data: if you do not actually need the phone number, you never store it. <Note> This is not only about sign-in. The same mechanism covers operation approval — see [second factor and step-up](/docs/scenarios/step-up), where `auth_time` plays the role of "the code was fresh". </Note> ## Where SMS still has its place If part of your audience has none of the supported messengers and no email, the mechanism has nothing to build on. The full channel list and its statuses are on [Supported channels](/docs/guides/supported-channels); the Email channel covers "no messenger, but there is a mailbox". # Second factor, step-up and action approvals Source: https://veriqa.app/docs/scenarios/step-up A fresh confirmation before a sensitive operation: a new authorize, the auth_time check, and why your own cookie is not evidence. Sensitive actions — deleting important data, changing payment details, granting someone access, approving a payment — deserve a fresh confirmation even from a user who is already signed in. Veriqa does that with one tap in a messenger instead of an SMS code. ## What you send A new authorize request **before the action itself**. There is nothing special in it: ```http theme={null} GET /connect/authorize ?client_id=my-app &response_type=code &scope=openid &redirect_uri=https://app.example.com/callback/step-up &state=… ``` ## What you check The `auth_time` claim in the `id_token` — the moment the confirmation completed. Freshness is your call: compare `auth_time` with the current time and decide whether it fits the window this operation deserves. <Warning> **Do not trust your own cookie.** The evidence of a fresh confirmation is `auth_time` from the new `id_token`, not the presence of a session in your application: a session only says the person signed in at some point. </Warning> <Warning> **The core does not read `max_age`.** Sending the standard OIDC `max_age=0` does no harm, but it does not affect Veriqa's behavior — do not build logic on it. There is one freshness mechanism here: a new authorize plus the `auth_time` check. </Warning> ## Why this works There is nothing to bypass: the issuer keeps no long-lived session. The only cookie between callback and authorize is single-use and is cleared as soon as the code is issued, so every fresh authorize request asks for confirmation in the channel again. ## Going stronger: two different channels One sensitive operation can be confirmed in **two different trusted channels** — WhatsApp plus email, for instance. To narrow a specific request to one channel, use `acr_values`: ``` &acr_values=channel:whatsapp ``` Multiple values are separated by spaces, and the user then picks from the ones listed. ## Enriching the profile along the way A confirmation is a convenient moment to collect what you are missing: request `scope=email` or `phone` and the application receives a **verified** contact during the confirmation itself — no separate form, no separate verification. The exact claims depend on the channel. ## What does not exist for this * **no "scenario" parameter** — Veriqa does not distinguish sign-in, step-up and operation approval; * **no server-side freshness check** — the acceptable `auth_time` window is yours to set and verify; * **there is a separate transaction entry, but it is not a step-up API.** `POST /api/transaction/confirmation` creates a confirmation transaction (the application authenticates with client credentials and receives a `transaction_id` and a way in for the user) for a declared action type and its typed slots. Step-up on this page is built with a repeat authorize and works without it. # Sign-in surfaces Source: https://veriqa.app/docs/scenarios/surfaces Desktop, smart TV, kiosk, a device with no keyboard: what changes for the integration, and what is supported. Veriqa's sign-in is cross-device by design: the **initiator** shows a QR code and the confirmation happens on the phone. That makes the range of surfaces a sign-in can start from wider than usual — the initiator only has to display a code and wait for the outcome. ## What works today | Surface | What the initiator shows | How it learns the outcome | | - | - | - | | Web application on a desktop | a QR on the sign-in page | the ordinary redirect to `redirect_uri` | | Desktop application | a QR in its own window | a redirect into the system browser, or status polling | | Smart TV, set-top box, kiosk with a browser or a webview | a QR on screen | live status over SignalR, or `GET /api/transaction/{id}/status` | | Smart TV, set-top box, kiosk without a browser, application with a backend | a QR from the server-to-server confirmation entry | the backend reads `GET /api/transaction/{id}/result` and exchanges the confirmed transaction for an `id_token` | | Mobile application | a link or a QR | confirmation in the messenger on the same device — the fallback path | <Note> Where the device has a browser or a webview, surfaces differ only in **how the initiator learns that it finished**: the flow itself is the same authorize → code → token. Where it has none, the application's backend creates a confirmation and exchanges it for the user's `id_token` — see [Smart TV and keyboard-free surfaces](/docs/research/smart-tv). A device with no backend of its own has no path yet: a full implementation of device flow is in progress. </Note> ## Surfaces with no convenient input This is where being cross-device pays off most. On a TV, a set-top box or a kiosk, typing a password means picking letters with a remote; a QR code plus a confirmation on the phone removes the problem entirely. Two things help on such surfaces: * **live status** — the `/hubs/auth` SignalR hub, with `GET /api/transaction/{id}/status` where WebSockets are unavailable; * **initiator context** — the confirmation message shows the person what they are confirming. On a kiosk or a shared device that matters more than on a personal laptop: see [Initiator context](/docs/concepts/initiator-context). ## When the initiator is a shared device An office kiosk, a terminal in a hall, a screen by the entrance: the device is shared, but the person confirming is one particular individual. Two things are worth keeping in mind. **A transaction belongs to a session, not to a device.** The single-use cookie between callback and authorize is bound to the latest load of the sign-in page: if the page reloaded, the confirmation belongs to a different transaction and Veriqa returns `browser_nonce_mismatch`. That is protection against confirming someone else's session — details in [Troubleshooting](/docs/guides/troubleshooting). **The QR on display is a secret for the length of the transaction.** On a shared surface it is worth shortening the sign-in screen's lifetime and not leaving the code up after it completes. ## Place as part of the scenario Binding a sign-in to a **physical place** (a QR on a printer or on a dedicated office screen) is a research topic, see [Place and identity](/docs/research/place-and-identity). ## What does not depend on the surface * the set of confirmation channels — that is about the user's phone, not about the initiator; * the claims and their contents; * the client requirement for a browser sign-in: a confidential client with a secret, or a public client with PKCE S256. The server-to-server entry is for a confidential client only. # Claude Code: a long command cycle Source: https://veriqa.app/docs/videos/claude-code-command-cycle The Claude Code plugin holds the start of a long command cycle until a person approves it. The same plugin guards the start of a long command cycle — here a `/cycle` run, which burns time and tokens once it starts. <video> Your browser cannot play this video. </video> ## What the video shows 1. The cycle is requested; the plugin holds it and the session shows a Veriqa QR code. 2. The person scans it and opens Telegram. 3. The bot asks whether Claude Code may start the cycle; the person taps **Confirm**. 4. The cycle starts only after the approval. ## See also * [Server-to-server transaction](/docs/basic-scenarios#server-to-server-transaction) * [Approvals in Claude Code](/docs/research/claude-code-approvals) # Claude Code: a tool call Source: https://veriqa.app/docs/videos/claude-code-tool-call The Claude Code plugin holds git push until a person approves it in the messenger. The sample Claude Code plugin guards a sensitive tool call. The agent is asked to push a branch to the remote repository. <video> Your browser cannot play this video. </video> ## What the video shows 1. Claude Code tries `git push`; the plugin holds the call and the session shows a Veriqa QR code. 2. The person scans it and opens Telegram. 3. The bot shows the wording declared on the host — Claude Code in demo-shop wants to push the branch — and the person taps **Confirm**. 4. The push goes through, and the approval lands in the audit trail. ## See also * [Server-to-server transaction](/docs/basic-scenarios#server-to-server-transaction) * [Approvals in Claude Code](/docs/research/claude-code-approvals) * [Server-to-server confirmation API](/docs/reference/confirmation-api) # Claude Code: two approvers Source: https://veriqa.app/docs/videos/claude-code-two-approvers A costly run with a cost estimate starts only after two people approve it in their messengers in turn. The costliest of the three plugin demos: a run estimated at 1 billion input and 50 million output tokens, about 6250 USD. It needs two approvers in turn — a developer, then a manager — each from their own messenger account enrolled beforehand. <video> Your browser cannot play this video. </video> ## What the video shows 1. The run is requested; the session shows a QR code for the developer. 2. The developer confirms in Telegram — step 1 of 2. 3. A new QR code is shown for the manager; the manager reads the estimate in their own messenger and taps **Decline**. 4. The run is **not approved**: nothing starts, and both decisions land in the audit trail. ## See also * [Server-to-server transaction](/docs/basic-scenarios#server-to-server-transaction) * [Approvals in Claude Code](/docs/research/claude-code-approvals) # Sign-in on a desktop Source: https://veriqa.app/docs/videos/desktop-sign-in Helio customer support in a desktop browser: sign-in by QR code and the customer card built from the channel claims. The Helio showcase sample — a customer support site — in a desktop browser. <video> Your browser cannot play this video. </video> ## What the video shows 1. On the public support page the user clicks **Sign in to see your tickets**. 2. The sign-in window shows a QR code; the user scans it with the phone camera. 3. Telegram asks to confirm the sign-in to "Helio Support", showing that the request comes from Chrome on Windows; the user taps **Confirm**. 4. The page shows **Sign-in successful!** and opens the customer card — name, Telegram `@username`, a masked user id — and the ticket list. ## See also * [Sign-in and registration](/docs/basic-scenarios#sign-in-and-registration) * [Initiator context](/docs/concepts/initiator-context) * [Helio showcase sample](/docs/samples/showcase/support-desk) # Step-up on a desktop Source: https://veriqa.app/docs/videos/desktop-step-up Helio in a desktop browser: deleting a ticket requires confirmation in the messenger. The signed-in Helio customer deletes a ticket. Deletion cannot be undone, so it is not applied until the customer confirms it in the messenger. <video> Your browser cannot play this video. </video> ## What the video shows 1. The customer clicks **Delete** on ticket #A-900; the **Deleting ticket #A-900** dialog shows a QR code. 2. They scan it with the phone and open Telegram. 3. The bot asks to confirm deleting ticket A-900 and warns it cannot be undone; the customer taps **Confirm**. 4. The dialog reports **Confirmed. The ticket is deleted.** and the ticket disappears from the list. ## See also * [Server-to-server transaction](/docs/basic-scenarios#server-to-server-transaction) * [Second factor, step-up and action approvals](/docs/scenarios/step-up) * [Server-to-server confirmation API](/docs/reference/confirmation-api) # Sign-in on a phone Source: https://veriqa.app/docs/videos/mobile-sign-in Helio on a phone: no QR code — the channel button opens the messenger on the same device. The same Helio sample in a mobile browser. When the sign-in starts on the phone itself there is nothing to scan: the user taps the channel button. <video> Your browser cannot play this video. </video> ## What the video shows 1. On the support page the user taps **Sign in to see your tickets**. 2. The sign-in window offers **Open in Telegram** instead of a QR code. 3. Telegram asks to confirm the sign-in to "Helio Support", showing that the request comes from Chrome Mobile on Android; the user taps **Confirm**. 4. Back in the browser — **Sign-in successful!**, the customer card and the ticket list. ## See also * [Sign-in and registration](/docs/basic-scenarios#sign-in-and-registration) * [Sign-in surfaces](/docs/scenarios/surfaces) * [Helio showcase sample](/docs/samples/showcase/support-desk) # Step-up on a phone Source: https://veriqa.app/docs/videos/mobile-step-up Helio on a phone: closing a ticket is confirmed in Telegram on the same device. The signed-in Helio customer closes a ticket from the phone. The confirmation happens in the messenger on the same device. <video> Your browser cannot play this video. </video> ## What the video shows 1. The customer taps **Resolved** on ticket #A-845; the **Closing ticket #A-845** dialog offers **Approve in Telegram**. 2. The bot asks **Mark ticket A-845 "Duplicate charge for one order" as resolved?** — the customer taps **Confirm**. 3. Back in the browser the dialog reports **Confirmed. The ticket is closed.** and the ticket turns **Resolved**. ## See also * [Server-to-server transaction](/docs/basic-scenarios#server-to-server-transaction) * [Second factor, step-up and action approvals](/docs/scenarios/step-up) * [Server-to-server confirmation API](/docs/reference/confirmation-api) # Sign-in on a TV Source: https://veriqa.app/docs/videos/tv-sign-in Nova Stream on a real TV: sign-in by QR code from the sofa, with no keyboard and no typing. The Nova Stream showcase sample on a real TV. The screen shows the sign-in window with a QR code; the user types nothing with the remote. <video> Your browser cannot play this video. </video> ## What the video shows 1. The TV shows **Sign in via messenger** with a QR code. 2. The user scans the code from the screen with the phone camera and opens Telegram. 3. In the bot they tap **Confirm** under the sign-in request. 4. The TV opens the catalog, signed in. ## See also * [Sign-in and registration](/docs/basic-scenarios#sign-in-and-registration) * [Sign-in surfaces](/docs/scenarios/surfaces) * [Nova Stream showcase sample](/docs/samples/showcase/streaming-tv) # Step-up on a TV Source: https://veriqa.app/docs/videos/tv-step-up Nova Stream on a real TV: buying a title is confirmed in the messenger by the signed-in viewer. In the same Nova Stream sample, a signed-in viewer buys a title. The purchase is not applied until the viewer confirms it in the messenger. <video> Your browser cannot play this video. </video> ## What the video shows 1. The viewer picks **Buy "Deep Field" — 4.99 EUR**; the TV shows a QR code. 2. They scan it with the phone and open Telegram. 3. The bot asks **Buy "Deep Field" for 4.99 EUR?** — the viewer taps **Confirm**. 4. The title is marked **In your library**. ## See also * [Server-to-server transaction](/docs/basic-scenarios#server-to-server-transaction) * [Second factor, step-up and action approvals](/docs/scenarios/step-up) * [Server-to-server confirmation API](/docs/reference/confirmation-api) # Sign-in on a website Source: https://veriqa.app/docs/videos/web-sign-in The demo sign-in window on a desktop: the same sign-in through Telegram, WhatsApp and the one-tap email. The demo sign-in window of a website on a desktop, shown three times — once per channel. Each time the user scans the QR code with the phone camera and confirms in the channel; no password, no form and no code to retype. <video> Your browser cannot play this video. </video> ## What the video shows 1. **Telegram** — the QR code opens the bot, the user taps **Confirm** under the sign-in request. 2. **WhatsApp** — the QR code opens a chat with a pre-filled message; the user sends it and taps **Confirm**. 3. **Email** — the one-tap email: the QR code opens a pre-filled email in the mail app, and sending it is the confirmation. 4. The browser lands on the result page with the claims the application received. ## See also * [Sign-in and registration](/docs/basic-scenarios#sign-in-and-registration) * [Passwordless sign-in and registration](/docs/scenarios/passwordless-login) * [Channel setup](/docs/guides/channels)