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."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.
Inbound verification level
Every channel section carries a requiredInboundVerification 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.
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
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.
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.
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 atwilio/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
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)
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
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).
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.
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
The frame — only after checking it on a device
Unicode frame — the most expensive one, unfit for a QR code
\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
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.
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.
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.
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.melink, 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
refvalue. When the user has never written to your account, opening theig.melink 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 therefvalue. 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.melink just returns them to it and therefvalue 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 tograph.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.
Platform limitations:
- The referral may not arrive while the Meta app is in development mode. The user opens the
m.melink, 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_localepermission. Without them the sign-in still completes: the user is identified by the page-scoped ID, and the token carries nogiven_name,family_name,pictureorlocale. - 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_localepermission, 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.
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.
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,
nameholds 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
wechatis 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 tochatapi.viber.com — the package carries no platform SDK.
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_orvq_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-phonebutton, 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
viberis taken. An HTTP channel registered under that name prevents the host from starting — rename it.
- 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.Pushis 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: falsethe 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(DmarcAlignedPassby 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.
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 theInbound 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
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:
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:
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 — amailto: 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. WithLoginConfirmation.Modeset toNonethe 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 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:
- 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 getsaccess_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.
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 forMailResendInterval — 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:00switches 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.
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
WithInbound: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
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
PollIntervalSecondsplus the time of one visit to the mailbox after it has arrived. - Only unread emails that arrived no earlier than
TokenTtlago 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/inboundanswers404with this provider. - In a multi-tenant installation every tenant with this provider is read by its own loop with its own mailbox settings.
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 theAuthservId.Authentication-Results: spf=pass …; dkim=pass …; dmarc=pass …— the value opens with a verdict (a word with=), so the server states noauthserv-id. There is nothing to put intoAuthservId: do not copyspf=passthere.
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.
Limitations:
VerificationPolicy: ProviderVerifiedcannot be combined withImapPolling— a mailbox supplies no provider-verified sender; the host does not start with this pair.- A mismatch between
ImapPollingand anInboundVerificationother thanoutbound_fetchis aWarningat startup, as with a messaging channel inPollingmode.
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 byVerificationPolicy, 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
- Microsoft 365 — Authenticate an IMAP, POP or SMTP connection 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 and OAuth 2.0 authorization code flow.
- Google — OAuth 2.0 mechanism names the scope of IMAP access, and Using OAuth 2.0 to Access Google APIs 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.
- 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 areSmtp 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.
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 theVeriqa: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
- 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 onlyHtmldoes 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 forHtml; - 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 withHtmland the last one withPlain— and the rule holds for both. A ladder whose lastHtmlstep names{browser}, with only thePlainone 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.
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
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
TheNormalization 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.
Letting the client pick a channel
A client can restrict a given sign-in to specific channels through the standard OIDCacr_values
parameter, in channel:{type} form:
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.