Skip to main content
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 or 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.
Program.cs
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.
The values of the shipped channels follow the mode you run them in: 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.

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.
appsettings.json
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 and the page that opens a confirmation put that line on their status line for every chat channel once the bot has answered the start: 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 and its token.
appsettings.json
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.
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, Getting the phone number and email.

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 console. Delivery through Twilio is set up below.
appsettings.json
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<TProvider>() 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.
appsettings.json
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:
twilio/quick-reply
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 <keyword> 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.

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:
Base text (en)
The text is configured where every other channel text lives — in your host’s locale files (wwwroot/locales/{language}.json, see 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.
wwwroot/locales/en.json
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: 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 code hard for a camera to read. 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.
No graphics — the default
Dashed rules + centered heading
Dashed rules
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):
The frame — only after checking it on a device
Unicode frame — the most expensive one, unfit for a QR code
The chosen sample goes into the locale file as a single line — line breaks are written as \n:
wwwroot/locales/en.json — the dashed-rules layout
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:
appsettings.json
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 (business or creator), a Meta app from the Meta for Developers 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.
appsettings.json
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 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 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 to 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. 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. 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). Permissions: instagram_business_basic and instagram_business_manage_messages (Meta: send messages). 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). Meta sends webhook notifications only to an app set to Live (Meta: 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). 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). 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 tapped 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).
  • 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.

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 console with that Page connected, and from the console — the app secret, a Page access token, the Page’s ID and its username.
appsettings.json
With "Enabled": true, PageId, PageAccessToken, AppSecret and WebhookVerifyToken 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 and GraphApiVersion have defaults; they are settings of the installation, while 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) — 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: 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. 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). 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 tapped 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.

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.
appsettings.json
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, the longest a transaction can live, so it never expires before the sign-in does. 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.
  • 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 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 — 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 has issued 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.
appsettings.json
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). 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.
  • 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 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.
appsettings.json
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. 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. 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 messaging channels use. The user sends a ready-made message in one tap — the 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.
appsettings.json
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:
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:
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 clicked, opens the mail client with the email ready to send.
  • When its button is clicked or tapped, 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: 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 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: 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 emailed-link page — 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. When the user answers on the page, the same chat gets the outcome receipt as a new message — the confirmed one on a “Yes”, the declined one on a “No” — without the values the calling party supplied, since the question was not asked in the chat. A “Yes” that leads into the claims completion gets its receipt when the completion ends. A receipt the channel refuses is logged and not retried; the answer to the application does not change. 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 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:
  • 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.
In the browser that took part in the sign-in, the button is not needed. When the sign-in holds the fingerprint and the IP address of the phone that scanned its hop QR code — hop mode 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 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.
appsettings.json
  • 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 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.
appsettings.json
The keys of Veriqa:Channels:Email:Inbound:Imap: 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). 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; 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 messaging channel 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. Password is ignored in this mode, and the OAuth section is ignored with AuthMode: Password. Veriqa signs in to the mailbox as Username with the OAUTHBEARER mechanism when the server offers it, and with XOAUTH2 otherwise. Which of the two standard grants (RFC 6749) 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.
With a refresh token
With the application's credentials
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: 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) 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). So the email is rewritten by a setting, with no code involved: set your own ladder of variants at the level you need.
appsettings.json
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;
  • there is one email, and each 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.
Program.cs
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:
appsettings.json
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:
List several (channel:telegram channel:whatsapp) to narrow the choice without fixing it. Without the parameter, every enabled channel is offered.

Checklist

1

Adapter registered and enabled

AddXxx() is called and the section has "Enabled": true.
2

Inbound verification declared deliberately

Every channel section sets InboundVerification, and the value matches the mode the channel actually runs in.
3

Webhook reachable

https://…/api/channels/… is publicly reachable over HTTPS and not blocked by your proxy.
4

Secrets in a secret store

No token or password sits in a committed configuration file.
5

Sign-in confirmed end to end

A real confirmation from a phone completes the flow and issues tokens.

Next steps

Configuration reference

Every section, key and default in one place.

Configuration & customization

Sign-in page design, localization, extension points.

Preparing for production

What to check before shipping.

Architecture

How adapters fit the rest of the connector.