Program.cs
samples/dotnet/custom-channel — an adapter library plus a
host that wires it up.
Not writing .NET, or running the standard Veriqa image you do not rebuild? Write the adapter as a
service of its own in any language and plug it in by configuration — see
An HTTP channel adapter in any language.
1. The adapter project
Reference the contracts package and nothing else from Veriqa:Acme.Chat.Adapter.csproj
Veriqa.Core.Contracts is MIT-licensed and is the free SPI. What the package contains, and which
neighbors by namespace stay in the MPL-2.0 core, is listed in the package’s own README (also shown
on its NuGet page). The server core (MPL-2.0) is a dependency of the host, not of your adapter.
2. Implement IChannelAdapter
Three members carry the flow; the rest are outbound helpers your platform may or may not have.
ValidateWebhookAsync — the security gate
Runs before your processing code — though not before everything: the request first passes through
the tenant demultiplexer, which answers 403 on an unknown tenant segment without calling your
adapter at all, and only then does Veriqa read the body and build the envelope. Return false and the request is answered with 403 and never
reaches your processing code; an exception thrown here is treated exactly like a rejection (logged,
answered 403), so a broken signature parser can never surface as a 500. Verify the platform’s
signature here, in fixed time, and reject by default:
Declare the inbound verification level of your channel — and document it
YourValidateWebhookAsync returning success tells the core only that “a check ran and passed”.
What the check was, the core does not know and cannot derive: it holds no closed list of
channels, and a statement the adapter makes about itself would guarantee nothing — the declaration
comes from whoever carries the risk: the integrator.
So your channel’s section carries the same required InboundVerification key the shipped
channels do:
appsettings.json
signature (the body is signed with a platform key), shared_secret (a secret
presented by the incoming request), outbound_fetch (there are no incoming requests, an outgoing
one fetches the events) and none (authenticity is not verified); the full description is in the
configuration reference.
For a channel plugged in the way this guide shows, the key is required but not checked at
startup. The check that refuses to start a host when an enabled channel has no value
walks the channels declared at core level — the very set the core builds its enabled channel list
from. An
AddChannel call registers the adapter and its route, not a core-level declaration, so
such a channel is not in that set — until you register one yourself, which
working per tenant has you do, and from that point the check covers your
channel like a shipped one. The value is still read by the same resolver and still reaches
the audit record, so a missing declaration shows up as an empty attribute in the audit log rather
than as a refusal to start.shared_secret because the
validation shown above compares a header with a configured secret; a channel that fetches its events
with an outgoing request of its own declares outbound_fetch.
ProcessInboundEventAsync — the raw body in, an intent out
Both methods receive the same envelope, ChannelInboundRequest: the HTTP method, the raw
bytes of the body, the headers and the query parameters (names compared case-insensitively,
repeated values joined with ", "). Bytes really are bytes: a signature is computed over exactly
those, with no decoding step standing between the platform and your HMAC. Veriqa does not interpret
the body format — JSON, form-encoded, XML, whatever your platform sends; parsing it with your own
serializer options is your job.
ChannelAuthStartResult (the user reached your platform from the deep link),
ChannelAuthStartByCodeResult (they sent the code shown on the sign-in page — see
Start by a code),
ChannelAuthConfirmResult / ChannelAuthDeclineResult (they pressed a button in your channel),
ChannelPhoneSharedResult / ChannelPhoneStepActionResult (answers to a
phone number request), ChannelUnaddressedResult or
ChannelUnrelatedResult.
The last two are close and easy to confuse. ChannelUnaddressedResult is a direct message to your
bot from a sender you have already vetted — a bare start command, free text — that names no
transaction; it carries the sender, so Veriqa can answer it when the registration declares a reply
(UnaddressedReplyText below). ChannelUnrelatedResult is everything Veriqa has no business
answering: a group chat, a callback of somebody else’s button, an event that failed your own sender
check. It carries no sender, and nothing is sent back.
Next to it sits a synchronous bool OwnsInboundEvent(ChannelInboundRequest request) — a cheap
answer to “is this a Veriqa update?” before anything is parsed. You need it when you own the
channel transport and hand updates out between your own bot and Veriqa: no event gets parsed twice
(this and the other ways to share a bot — You already have a bot).
A default implementation is already provided — it looks for any CallbackDataPrefixes marker (vq_confirm_,
vq_decline_, vq_phone_skip_, vq_phone_cancel_, vq_menu_, auth_) in the raw bytes of the
body — so implementing the member is optional. vq_menu_ marks a button of the
post-completion menu: what follows it is a value
from the deployment’s configuration, not a transaction identifier. The
answer is one-sided: an event from a Veriqa button or deep link always answers true, but true
on its own does not promise the event is actionable — the search is substring-based and auth_
carries no vendor prefix, so the word oauth_ in a user’s message answers true as well. Override
the member with a precise check of your own wire format if a false positive costs you something.
Capabilities — what your channel can do
A channel declares what it can do as a fact, not as a stubbed-out operation: one
ChannelCapabilities object states whether the channel can confirm a sign-in inside itself
(SupportsInChannelConfirmation), which content kinds it accepts, whether it can update its own
message (SupportsMessageUpdate), whether it shows the user where a transaction stands
(DeliversOutcomeNotice), whether it supplies the recipient’s locale (ProvidesRecipientLocale)
and whether it renders emoji (RendersEmoji). Veriqa never calls what a channel has not declared —
so “cannot” is written once, in one place:
About confirmation you state exactly one fact: your own ability to ask the question and receive the
answer inside the channel. How you do that (inline buttons, a web view inside the messaging app,
anything) is your business — the core neither knows nor asks. An adapter never names a surface of
the core: whether a confirmation is needed at all is the core’s policy, and the page that asks
the question when the channel cannot is the core’s own. The fact defaults to “cannot”: an already
compiled adapter that knows nothing about it will not be handed a confirmation
it cannot display.
RendersEmoji is the one three-state fact of the object: it decides which of the two in-channel
wordings a terminal receipt is addressed with, and that is the channel’s rule rather than the
render point’s. Leaving it out says “no rule of my own” — the core then answers from its own list of
the messaging apps it ships an adapter for, which names no third-party channel.
Outbound operations
SendMessageAsync(ChannelMessage) delivers a message and returns a reference to what was sent
(ChannelMessageRef) — or null on success when the channel has no addressable reference.
SendConfirmationPromptAsync sends the confirmation prompt and returns the same kind of reference;
Veriqa stores it, not you. ReportOutcomeAsync(TransactionOutcomeNotice) states the intent “show
the user where this transaction stands” — which platform calls that takes is your decision,
and it is called only if your channel declared DeliversOutcomeNotice. The notice also carries
DisplayIntent — where the deployment wants the receipt to appear: in place of the message that
asked the question (ReplacePrompt, the shipped value) or as a message of its own (NewMessage).
Honoring it is optional: your platform may have nothing editable to replace, and showing the outcome
the only way it allows is not a failure — nothing is logged as an error and the result you return
does not change.
The notice is terminal in every case but one. Outcome is confirmed, declined, expired or
failed when the transaction has ended, and awaiting_claims when the sign-in is confirmed and
waits for the details your application still asks for; IsTerminal tells the two apart. Show that
state the way you show a receipt: answer the press, put StatusText where the question was. A
sign-in that then ends confirmed, declined or failed is reported again, always as a new message.
The state is reported for the press that confirmed the sign-in. A press made later, while the
sign-in is still waiting, brings the same notice only to a channel that declared
SupportsMessageUpdate. A channel that cannot edit its messages is not called for it: the state
would arrive as a new message under the request of the step in progress and could take that
request’s buttons away. Instead, while the sign-in waits for the phone number, such a channel is sent
the phone number request once more: on many platforms the press itself lands in the chat as a
message from the user and removes the buttons of the request, and the repeated request brings them
back. If your platform expects an answer to every button press, give that answer yourself when you
receive the event.
An expiry is reported for every transaction started from your channel, whether the question was
asked in the chat or not. PromptMessage is set only when the question went into the chat, your
channel declared SupportsMessageUpdate and returned a reference from the prompt. Without it —
the question was asked on the web page, the sign-in needed no question, or there was no reference
to keep — show StatusText as a new message. The only expiry that goes unreported is one whose
record Veriqa no longer has: by default it is kept in the memory of the instance that handled the
start (channel stores).
Honoring NewMessage is two steps rather than one, and the second is the one easy to miss:
deliver the receipt as a new message and take the buttons off the message that asked the question,
leaving its wording alone. Skip it and the user is left looking at a settled transaction with a live
button under it — a question that answers nothing and still invites a press. Take the buttons away
with whatever operation changes a message’s buttons without rewriting its text; if your platform has
no such operation, leave the question untouched rather than blanking its text — an unhonored intent
is not a failure, a question that lost its wording is one. Whether the buttons came off decides
nothing about the result you return: the delivery of the receipt does.
Phone number on request
A channel that can give the user’s phone number declares it inCapabilities.PhoneNumber: how it
gets the number and whether the platform proves that the number belongs to the sender. What the
core does with the number — when it asks, how long it waits, what the token carries — is described
in Phone number.
PhoneNumber out means PhoneNumberCapability.None: the channel gives no number, and a
sign-in that requires one does not offer the channel.
With Automatic you put the number, in E.164, into the snapshot’s PhoneNumber and say whether it
is proven in PhoneNumberVerified (left null, the claim goes out as false).
With OnRequest the core calls SendMessageAsync with a message of kind PhoneNumberRequest:
Text is the request, and PhoneNumberRequest carries the label of the share button
(ShareButtonLabel), the second action (SecondaryAction — Skip or CancelSignIn) with its label
(SecondaryButtonLabel) and the TransactionId. The texts arrive localized. Show the platform’s
“share contact” button and the second button next to it: a button with a payload carries
CallbackDataPrefixes.PhoneSkip or CallbackDataPrefixes.PhoneCancel followed by the transaction
identifier, and a keyboard button that can only send text sends its label. Return the answers as:
- a shared contact —
ChannelPhoneSharedResultwith the number in E.164 with the leading plus and the sender’s snapshot. Normalizing the number to E.164 is the adapter’s job: platforms rarely promise that format, and the core counts a number outside it as absent.E164PhoneNumber.TryNormalizefromVeriqa.Core.Contractsdoes that. When the contact fails your platform’s ownership check, setOwnershipRejected = true: the core does not take the number and asks again. A contact names no transaction — the core finds the request by the chat; - the second button with a payload —
ChannelPhoneStepActionResultwith the parsedTransactionIdandAction; an identifier that does not parse makes the eventChannelUnrelatedResult; - the second button as plain text —
ChannelUnaddressedResultwithText: the core compares it with the label it sent into that chat.
ReportOutcomeAsync
carries PhoneRequestMessage —
the reference your SendMessageAsync returned for the request. Take down what the request offered,
its buttons or its input keyboard, by the means of your platform. The reference is absent when you
returned none, and a notice of awaiting_claims never carries it: the step is still running.
If you route updates with OwnsInboundEvent, override it: the default recognizes the button
markers, but a shared contact carries none. Answer true for a contact event and keep the marker
check with CallbackDataPrefixes.ContainsAnyMarker(request.Body.Span) — the default body of an
interface member cannot be called from your class. A button that sends plain text carries no marker
at all, so in this mode its press reaches your own bot: an Optional number is then skipped when
its window runs out, and a Required one waits until the transaction expires. The built-in Telegram
adapter has the same limitation.
Claim names — which are yours and which belong to the core
Everything you know about the user goes into the typed fields of the snapshot: name, username, locale, phone and whether it is proven (PhoneNumberVerified), email and whether it is verified
(EmailVerified) — each has its own field. The core issues the claims from them; email_verified
goes out only next to a non-empty email, phone_number_verified only next to a non-empty phone
number. Free-form
names live in AdditionalClaims, and there the three-group discipline applies (registry
VeriqaClaimTypes):
A key that breaks the rule is dropped with a warning in the log — authentication does not fail.
So you cannot claim
email or locale with a string of your own: put the value in the snapshot
field. Names are compared case-insensitively — Email_Verified is the same name as
email_verified, so re-casing a letter does not get around the reservation. Prefixing with your own
channel type makes collisions between adapters impossible.
Letter case decides whose name it is, but grants no freedom in writing it: a claim of your own is
taken only in the declared spelling. With ChannelType = "acme-chat" the key ACME-CHAT_workspace
is dropped — write the prefix the way the channel type is written (veriqa_channel likewise, as the
registry spells it). What follows the prefix is yours: acme-chat_Workspace goes through. But two
keys of your own differing only in letter case are one claim: one of them is taken, the other
goes to the log as a warning. Which spelling survives is not defined: the set reaches the check
frozen, and its enumeration follows the layout rather than the order you wrote it in. Send one
spelling — a case-variant pair means losing one of the values.
Error codes — from the declared registry, not strings of your own
You return a failure asResult<T>.Failure(code, message), and code comes from
VeriqaErrorCodes — the code registry declared in the same MIT package. A string of your own looks
like it works, but the core does not understand it: it branches on declared codes and tags
telemetry with them, while an unknown code stays a line in a log. A body in an unrecognized format is
UnsupportedEventType; a body without the identity fields is IdentityExtractionFailed. The meaning
of each code is in the XML documentation of VeriqaErrorCodes.
One code stands apart: ChannelCannotContinue — “I cannot carry this confirmation out”. Return
it from SendConfirmationPromptAsync when your channel physically cannot ask the question for this
transaction, and the core ends the transaction in failure with that same reason code instead of
waiting for its TTL. The user sees the error in the browser right away, and
nothing reaches them in the channel. Any other failure code means “it did not work right now”: the
transaction stays waiting for a retry or its TTL. You report no reason and do not pick what happens
next — you declare your own inability, the core decides.
GetDeepLinkAsync — where the button and the QR code lead
The link you return is where your channel takes the user. The channel button always opens it as is.
The QR code carries it too — unless hop mode is on for your
channel (it applies only to a channel declared in the host): then the QR code carries a short one-time hop link to your Veriqa server, and opening it on
the phone leads to the same link your method returned. Nothing in your adapter changes for hop mode —
the core wraps the link above your contract.
Carry the transaction id in the link so your platform can post it back on the webhook:
Start by a code — when the link cannot be relied on
Two kinds of platform need more than a link. One has no link that both opens the chat with the bot and carries a payload, so the transaction cannot ride in it at all. The other has such a link but does not always deliver what it carries. A channel on either declares a start by code: the core gives the transaction a short code of decimal digits, shows it on its own pages, the user sends it to your bot as a message, and you hand it to the core.LinkCarriesTransaction is a fact about your link, and the core decides the rest from it:
The two conditions of a hop link stay in force for the first column: the channel is
declared in the host, and the issuer is an absolute HTTPS URL. Where
either is missing, the QR code carries your link — it opens the chat, and the user reads the code off
the sign-in page. A host where such a channel is declared and enabled maps the hop route even with
hop mode off.
Whatever the user sent comes back to you on the webhook. Strip your command and hand over the rest:
ChannelAuthStartResult.
What belongs to whom:
- The format of the message is yours. The core substitutes the code into
MessageTemplateand shows the result; it does not interpret the wording. A template that is blank, longer than 64 characters (ChannelCodeStart.MaxMessageTemplateLength) or does not hold{code}exactly once cannot be used: a channel withLinkCarriesTransaction = falseis then left out of the page, like a channel that failed to build its link, and a channel withtrueis shown without the block. - The code is the core’s. Do not normalize it and do not check its length: the page shows it in
groups of three digits, the user may send it with spaces or dashes, and the core drops both. The
length is one setting of the installation,
Veriqa:CodeStart:CodeLength— an integer from 6 to 10, 6 by default; a value outside the range stops the host at startup. One code serves the transaction on every channel that declares the start, works once and lives exactly as long as the transaction. - The attempt limit is the core’s. An event with a code is charged to the same per-user
budget as a start by the link, before the code is looked up. A code that names no live
transaction — unknown, expired, already used — is counted like a start whose transaction cannot be
served and answered with the text the registration declares for it,
UnmatchedCodeReplyText: one text for every reason, so the answer does not tell whether the code ever existed. A registration that declares none answers withStaleLinkReplyText, or with silence when it declares neither. - A message with a code carries no Veriqa marker, so the default
OwnsInboundEventdoes not claim it. Override the member if you route updates by it.
A code is shared by the whole installation: it is unique among all live codes, whichever tenant
the sign-in belongs to, and it is found whichever webhook address the message arrives at — with
the tenant’s segment or without it. All tenants draw from one space of codes, so an installation
with many sign-ins in progress at the same time should raise
Veriqa:CodeStart:CodeLength.samples/dotnet/custom-channel: its README walks through the start by
code (AcmeChat:StartByCode) and the fallback (AcmeChat:CodeFallback).
3. Name your channel
The channel type is a contract, not a display string. It must match:kakaotalk, acme-chat, zalo. The
value is matched ordinally against acr_values and becomes a URL path segment, so anything else
(KakaoTalk, my_channel, an empty string, a value with a trailing newline read from configuration)
fails the startup with a message naming the allowed format. The same fail-fast applies to a
duplicate registration of a type — which covers a type a shipped channel already claimed, because
the shipped channels are registered through this very AddChannel and live in the same registry —
and to the type passed to AddChannel differing from the one your adapter returns from
ChannelType — the two must be equal,
because everything downstream matches the channel by the adapter’s ChannelType. That last check
runs on host start, not on the webhook mapping, so an outbound/polling-only channel registered with
mapWebhook: false is diagnosed just as strictly.
That last check has one boundary: a cross-wired swap — one channel’s adapter registered under
another channel’s type — is caught by cross-checking the adapters’ runtime types. If your host
layers a sweeping decorator over every
IChannelAdapter (Scrutor
Decorate<IChannelAdapter, …>() or a Castle proxy over the whole set), every adapter shares the
decorator’s single runtime type and the wrapped types are hidden behind it: such a swap becomes
indistinguishable from a legitimate decorator and is not rejected — swap detection is
best-effort in that configuration. It stays exact for native adapters and for per-adapter class
proxies. The plain case — the type passed to AddChannel is declared by no adapter at all — is
always rejected, decorators or not.4. The webhook endpoint
Registering a channel mapstelegram or whatsapp.
Answer semantics:
That last row is absolute: a
Failure result and even an unhandled exception from
your adapter are logged and still answered 200 with an empty body. The rule is the same for every
channel: the routes of the shipped channels are served by the same handler, so an adapter exception
does not turn into a 500 there either. Messaging platforms disable webhooks or storm retries on
non-2xx, and the response body never leaks error details.
The 1 MB limit belongs to the request body: Veriqa reads the body itself, once and entirely into
memory, while building the envelope — after the tenant demultiplexer and before validation. An
over-limit body aborts that read as it arrives, without being read to the end; a failed read is not
a rejection verdict, so the answer is 200 plus a Warning log entry, the same on the generic
route and on the built-in ones. The over-limit update is lost, but the platform is not pushed into
endless redelivery; the symptom is the log entry, not the status code.
If your channel is outbound-only or polls the platform itself, register it without a route:
Registration settings
mapWebhook is the one setting the short overloads spell out. Everything a registration can state
lives in ChannelRegistrationOptions, which both overloads accept in place of the flag — the
adapter type and the factory form alike:
An empty or blank string means the same as
null: the channel stays silent.
The shipped channels take this very path: their status wording and their handshake are declared
here as well.
Telegram declares a StaleLinkReplyText, WhatsApp does not: a message outside an approved
template is chargeable there, so answering every stale link would cost the installation money. For a
channel of your own, decide by its platform’s pricing. UnaddressedReplyText is declared by
Instagram and Messenger: a first tap there may arrive without the transaction, and the reply tells
the user to send the code shown on the sign-in page or to scan the QR code again. Their adapters
hand over only a tapped button as ChannelUnaddressedResult — a typed message that concerns no
sign-in is ChannelUnrelatedResult and gets no answer — and each has a setting that switches the
reply off (Instagram, Messenger). The
other shipped channels leave it null and send nothing. UnmatchedCodeReplyText is declared by the
same two channels, the ones that start a sign-in by a code.
The platform’s verification handshake (GET)
Platforms of the Meta family confirm ownership of a webhook by sending a GET to the same path with a challenge to echo back. Declare the query parameter that carries it, and Veriqa maps the GET route beside the POST one:ValidateWebhookAsync as a POST, so the verify token, the spelling it arrives under and the mode
check are your adapter’s business. Veriqa only knows which query parameter holds the value it has to
echo. The route is fail-closed, exactly like the POST one:
Declare nothing and the GET route is not mapped at all — the path answers
405, which is the
behavior a channel whose platform has no handshake wants.
5. Show the channel in the sign-in window
A registered channel already appears as a button — by default labeled with its raw channel type and without an icon. Implement the optionalIChannelDisplayMetadata to give it a name and a glyph:
DisplayName is a single non-localized string — brand names are not translated, exactly like
“Telegram”; a blank label — or one longer than 32 characters, which would break the window layout —
degrades to the raw channel type. IconSvgPath is the inner markup of a
24×24 viewBox and is embedded into the page, so it must be static markup you own — never user input.
It is checked against an allowlist before rendering: plain shape elements only (path, circle,
ellipse, rect, line, polyline, polygon, g) with double-quoted attributes, no style and
no on* handlers, and at most 4096 characters. Anything else is dropped with a warning in the log
and the channel renders without an icon. A channel without a brand color renders with the
neutral primary color of the window.
If the user sees your channel’s chat under a name of its own — a bot username, an account
handle — implement the optional IChannelBotName as well:
GetDeepLinkAsync is. null, a blank
value, a value longer than 32 characters or an exception produces the same texts, worded
without a name — the channel name is never put in its place.
A configured name comes first. The BotName key in the
section of the channel is read before your
adapter is asked, and a name stated there is shown instead of the one GetBotNameAsync returns —
the method is then not called at all. So a channel that does not implement IChannelBotName still
gets a name on the pages when the integrator states one.
Status messages your channel sends back to the user come from Veriqa’s own localized defaults —
you do not have to supply them. For a sign-in these are “Sign-in confirmed” — or “Sign-in
confirmed. Tap Back to return to the sign-in page.” when the sign-in was started on a phone or a
tablet — and “Sign-in declined”; for a confirmed action they are the neutral outcome receipts “Confirmed ✅”,
“Declined ❌” and “Time expired ⌛”. The error text for a failure while handling the answer —
“Something went wrong. Go back to the sign-in page and start again.” — is the channel’s own and does not depend on the transaction type.
QR code size
The sign-in window draws every channel’s QR in a box sized by the--veriqa-qr-size CSS token
(220px by default). You override it from your own stylesheet, whose path goes into
Veriqa:AuthPageDesign:CustomCssPath:
#veriqa-panel-acme-chat .veriqa-qr img { width: … } gets the same result by bypassing the
token and is therefore forbidden: the next change to the window layout will drift it out of step
with the other sizes.
You may not shrink the QR below either of two thresholds:
- 200×200px — the minimum physical target size for a camera;
- 2.86px per module — the density:
box size ÷ the QR's total module count including the quiet zone.
PixelsPerModule default of 6 is sized for
the deep links the core ships: the most compact of those QRs is 45 modules, and 45 × 6 = 270px
covers the box. In a 220px box the default suffices from ⌈220 ÷ 6⌉ = 37 modules up. A QR only gets
sparser than that from a very short link: acme://c/8f3k9d — 33 modules, 33 × 6 = 198px,
already stretched in the shipped box. The link from §2, the one that carries the transaction id
(43 characters), does not fall into that case — it comes out at 45 modules and the default covers it.
So you need the entry below only if you keep a short code of your own with your own mapping to the
transaction. The host starts either way: the value is inside the allowed range, and your QR’s module
count is unknown to the core — you are the only one who can check the rule.
If that is your case, the fix is an entry for your own channel:
appsettings.json
⌈220 ÷ 29⌉: 29 modules is the most compact QR that can exist at all (version 1 with
the quiet zone), so 8 covers the shipped box whatever the length of your deep link. If you enlarge
the box, recompute this value with the same formula.
The allowed range is 5…35. The upper bound is that same formula applied to a 1000px box and the
same 29 modules, so the range covers any box that fits on a screen, for any channel. A value outside
it stops the host at startup — but the reverse does not hold: landing inside the range confirms
nothing, and satisfying the formula is still up to you.
6. Register it in the host
The host is the application that embeds Veriqa, so it references the server core:Program.cs
appsettings.json.
Use AddChannel<TAdapter>("acme-chat") instead of the factory overload when the adapter’s
dependencies can be resolved from the container.
Declare the channel next to AddChannel. AddChannel registers the adapter and its route, not
the channel’s core-level declaration (CoreChannelDeclaration) — that is a separate step, the same
one the shipped channels take. The declaration is what puts your channel into channels_enabled,
the set of channels available at core level, and everything that reads that set skips a channel
missing from it: hop mode never wraps its QR code, polling
never gets a tenant to run for, and the startup check of inbound verification stays off it. The host
still starts — with a warning in the log naming the channel that has an adapter but no declaration. How to declare
it is shown in Working per tenant.
7. Add the channel to the supported list
An adapter that ships as part of Veriqa gets registered in one more place: the Supported channels catalog and its version on the site (veriqa.app/channels). That list is maintained by hand — a channel missing from it is not a
supported channel, however many other places mention it.
An entry states the same three things as every other one: which claims the channel adds on top of
the mandatory ones, whether confirmation happens inside the channel, and whether the user sends a
secret message — or the messaging app hands the code to the bot by itself. A channel still in progress goes
under the in-development or planned groups, not the built-in one.
None of this applies to an adapter of your own that stays with you — the list describes what Veriqa
ships. Still, keep an entry like it in your own documentation: people ask the same questions
about any new channel.
Working per tenant
Multi-tenancy is inside this SPI, not outside it. A channel of your own reads a tenant’s credentials, texts and limits the same way the shipped ones do — through the same public pieces, and under the same one rule: the tenant is stated by whoever legitimately knows it, and stated once. The set of tenants comes fromIPollingTenantSource. Resolve it from the container and ask it
which tenants your channel is active for; a null element is the default implicit tenant of a
self-hosted installation. It has one precondition: the set is built from the core-level
declarations of the registered channels, and AddChannel does not register one — it registers
the adapter and its route. So declare your channel in the host, next to the AddChannel call:
Program.cs
isEnabled is asked again every time the available set is built, so it has to read a
source that survives a re-read of the configuration: a monitor, not an options object bound once —
that one would freeze a deployment-side edit out of the answer. usesPolling is a startup
decision, and IOptions is what the shipped channels read it through. Both need the section in the
container (builder.Services.Configure<AcmeChatOptions>(builder.Configuration.GetSection("AcmeChat"))),
which is one line more than the manual binding in §6 does for the adapter itself.
That isEnabled is also what puts your channel into channels_enabled at core level. Without the
declaration the tenant set comes back empty and no polling starts — and the startup check the note
in §2 describes stays off your channel; with it, an enabled channel without an InboundVerification
value stops the host exactly like a shipped one.
State the tenant with a scope, around the processing, on your own background path:
ChannelTenantContext.BeginScope(null) — rather than by
omission: a credential read with no scope open at all is indistinguishable from a path that forgot
to state its tenant, and nothing downstream can tell the two apart for you.
On the webhook you do not open a scope. The tenant of an incoming request is already stated —
by the route (/api/channels/{channelType}/webhook/{tenant}), before your adapter is called. One of
your own opened there would repoint every credential read below it, the core’s included, at a tenant
of your choosing instead of the one the host named.
You resolve your own credentials; you do not receive them. Declare your key and resolve it
through the public IConfigurationResolver with ResolutionContext.ForTenant(tenantId) — the same
canonical resolver and the same level order the shipped channels go through. The public client factory
IChannelClientFactory is not needed for that: it builds and caches a client per
(channel, tenant), which is a different job. How a key of your own is declared — the catalog, the
address on each level, the registration — is in
Declare your own configuration keys.
An HTTP channel adapter in any language
Everything above is the .NET path: your adapter runs inside the host. The second path needs no .NET and no host code at all. The HTTP channel is an adapter shipped with Veriqa that stands in for yours inside the host and forwards the work over HTTP to an external adapter — a service you write in any language and run next to Veriqa or inside your own perimeter. The channel is added by configuration.The wire contract between Veriqa and an external adapter is version
1.0 and ships with the status
preview. Declaring it stable is a separate decision; until then a revision of the contract may
still change it.How the traffic flows
- The platform talks to your adapter, not to Veriqa. Your adapter receives the platform’s webhooks or polls it, verifies them and answers the platform’s challenge handshake itself. Veriqa maps no GET route for an HTTP channel.
- Your adapter posts events to Veriqa on the routes every channel has,
/api/channels/{channelType}/webhookor/api/channels/{channelType}/webhook/{tenant}. An event of a transaction goes to the route of that transaction’s tenant — the one thetenantfield of thedeep-linkandsend-promptcalls of that transaction carried;nullmeans the route without a tenant segment. Veriqa does not tell you the tenant again: keep the mapping “transaction → tenant” yourself. - Your public entry is yours to protect. Veriqa’s rate limit guards its own routes; the entry your adapter opens to the platform needs protection against junk traffic of its own.
- Veriqa calls your adapter at one address,
Http:BaseUrl, for everything it needs from the channel. - The platform credentials stay with your adapter. Veriqa never asks for them and never stores them.
- One registration is one external adapter: one address, one transport secret, one set of channel
facts. Without
Http:Tenantit serves every tenant of the installation. A tenant that brings an implementation of its own gets a registration of its own, under its own name (for exampleshop-a-zalo), withHttp:Tenantnaming that tenant. - The standard Veriqa image always registers HTTP channels: a channel is added with environment
variables and a restart of the container, without rebuilding the image. An embedded host references
the
Veriqa.Core.ChannelAdapter.Httppackage and adds one call:
Program.cs
Configuration
Every sectionVeriqa:Channels:{channelType} with an Http subsection becomes one HTTP channel under
the name of the section:
appsettings.json
appsettings.json. A channel type with a dash cannot be exported from a shell, but the
environment: of a compose file and docker run -e take it:
- The channel facts and the button are stated by the operator, with
Http:Capabilities:*(one key per property ofChannelCapabilities),Http:DisplayNameandHttp:IconSvgPath. There is no network manifest: Veriqa does not ask your adapter what it can do. A fact left unstated takes the safe default ofChannelCapabilities— every booleanfalse,plain_textas the only content kind, no emoji rule of the channel’s own (RendersEmojiunset), no phone number (The phone number). Http:Tenantnames the tenant that owns the registration. It serves that tenant alone; for every other tenant the channel does not exist — not on the inbound route, not for events, not in outbound calls. Unset, the registration serves every tenant of the installation.Http:AdapterSettingsis an opaque string, up to 8192 characters, that Veriqa forwards verbatim with every call and never reads. It has a core level and a tenant level; the value of a tenant replaces the core value entirely.Http:AllowInsecureHttpadmits anhttpbase URL. Use it only for an isolated internal network (a neighboring container); Veriqa writes a warning at startup.- Every
Http:*value is fixed at startup, exceptHttp:AdapterSettings, which is resolved per call. Changing the others takes a restart. - The name of the channel follows the format of Name your channel, and the
names of the channels shipped with Veriqa are reserved even when the shipped channel is not wired
into the host:
telegram,whatsapp,max,email,instagram,messenger,wechat,viber. The reserved list grows as new built-in channels ship. - A configuration error found at startup stops the host, with a message naming the key or the
channel section — for example, a missing or relative base URL, an
httpbase URL withoutHttp:AllowInsecureHttp, a missingHttp:TransportSecret, a secret not in thewhsec_form, a secret shared by two registrations, a forbidden deep-link scheme, a timeout not in thehh:mm:ssform,Http:AdapterSettingslonger than 8192 characters, a reserved name or one another channel of the host already has. An emptyHttp:Tenantis an error too: to serve every tenant, remove the key.
AddChannel channel (the note in §2), InboundVerification is checked at startup
for an HTTP channel: the registration declares the channel to the core, so an enabled HTTP channel
without a value stops the host.
The phone number
A channel that can give the user’s phone number says so inHttp:Capabilities:PhoneNumber — the same
fact a built-in channel declares in code (see Phone number on request and
Phone number). Left unstated, the channel gives no number and is not offered to
a sign-in that requires one.
appsettings.json
PhoneNumber
written as a single value instead of a section, a value outside the names above, Acquisition
written as a single value instead of a list, Proof: Proven with no Acquisition, and OnRequest
without phone_number_request in SupportedMessageKinds — the request would never reach your
adapter.
Proof is the ceiling of what your events may state. identity.phone_number_verified: true of an
event is taken only from a registration that declares Proven; from any other, Veriqa lowers it to
false and writes a warning. false and an absent field are never raised: declaring Proven does
not make a number proven that your adapter said nothing about.
The start by a code
A channel whose link cannot carry the transaction, or whose platform does not always deliver it, declares the start by a code inHttp:Capabilities:CodeStart — the same two members a built-in channel sets in code. The core shows
the code on its pages, the user sends it to your bot, and your adapter posts it with the event
auth_start_by_code (see The wire contract).
appsettings.json
Http:UnmatchedCodeReplyText is the reply to a code that names no live sign-in; unset,
Http:StaleLinkReplyText answers instead. The template is judged where the channel is shown, as for
a built-in channel, not at startup. Three mistakes stop the host from starting: CodeStart written as a single value
instead of a section, LinkCarriesTransaction that is not true or false, and MessageTemplate
without LinkCarriesTransaction. With LinkCarriesTransaction: true the spare code block and its
setting SpareStartCodeEnabled work for the HTTP channel as for any channel. An auth_start_by_code
from a registration that does not declare the start is not processed.
For now the start by a code is open only to a registration without an owner. A registration that
declares it and sets
Http:Tenant stops the host at startup, with a message naming both keys.The wire contract
Every call from Veriqa is a signedPOST of a JSON envelope to Http:BaseUrl: version, operation,
channel_type (one entry may serve several registrations), tenant (or null), adapter_settings
(or null) and payload. The answer is 200 with an envelope of version and exactly one of
result or error (code and message).
The fields of every payload, result and event are in the machine-readable
JSON Schema of the wire — this guide does not repeat them. Rules the schema does
not express:
- Error codes. The codes the wire names are in the schema, and any other code is accepted too. The
code of an
erroris written to the log and decides nothing else, with one exception:channel_cannot_continuein the answer tosend-promptends the transaction in failure right away. Any othererroracts by its operation, and the call is not repeated after it:send-prompt— the transaction stays open and waits for a newauth_startfrom the user or the end of its lifetime;deep-link— the user gets no way into your channel: the sign-in page is rendered without it;send-message,report-outcome— the refusal is logged and counted in the metrics, nothing more.
deep-linkis idempotent: a repeated call for the same transaction returns the sameurland creates no new state.- Answers are bounded. An answer is read up to 1 MB and refused past it, redirects are not
followed, and a deep link is accepted only as an absolute URI with the
httpsscheme or a scheme listed inHttp:DeepLinkSchemes, no longer than 2048 characters. - Timeouts and retries. One attempt waits
Http:OperationTimeout(10 s by default);deep-linkwaitsHttp:DeepLinkTimeout(2 s) because it stands on the path of the sign-in page.send-prompt,send-messageandreport-outcomeare retried on a transport failure — no connection, a timeout, a5xx, a429— up to three times, all attempts withinHttp:RetryBudget(20 s). Nothing else is retried: a200witherror, a3xx, any other4xx, an oversized or malformed answer.deep-linkandhealthare attempted once. - The second action of a phone number request comes back in either of two ways, whichever your
platform allows: the
phone_step_actionevent, or anunaddressedevent whosetextequals the caption of the second button. AnactionVeriqa does not know makes the eventunrelated. The share button itself is answered withphone_shared. - The phone number of an identity.
identity.phone_numberof any event is taken as the user’s number — the automatic way — andidentity.phone_number_verified: trueonly up to theProofthe registration declares (see The phone number). auth_start_by_codegoes on the route your bot’s other events use. It names no transaction, so the rule of the transaction’s tenant does not apply to it: Veriqa looks the code up across the whole installation, whichever route the event came on, and takes the tenant from the transaction it finds.- Refuse an event envelope on your call entry. A body with
eventinstead ofoperationthat arrives atHttp:BaseUrlis an event reflected back at you; answer it with anything but a200withresult. - Serve calls while your own request is open. On
auth_startVeriqa sendssend-promptto your adapter before it answers yourPOSTof the event. An adapter that cannot take calls concurrently with its own requests to Veriqa times the prompt out, and the transaction waits for its lifetime.
The signature
Both directions are signed with the transport secret of the registration by the Standard Webhooks schemev1 (HMAC-SHA256) — every language has
a library for it. The headers are webhook-id, webhook-timestamp and webhook-signature, and a
timestamp is accepted within 5 minutes either way of the receiver’s clock.
- Rotation without downtime. Put the new secret in
Http:TransportSecretand the old one inHttp:PreviousTransportSecret: the sender then signs with both, and the receiver accepts either. Veriqa writes a warning at startup until the previous secret is removed. - Answers are not signed: their authenticity rests on the connection Veriqa opened to the
configured address — which is why
httpsis the default. - A wrong signature is
403, without an envelope and without any effect, in both directions: Veriqa answers your unsigned or mis-signed event that way, and your adapter must answer such a call the same way. - Every registration has a secret of its own. Two registrations sharing one would let one pass as the other, so the host refuses to start.
Idempotency
- The
webhook-idofsend-prompt,send-messageandreport-outcomeis the idempotency key. A repeat with a key you have already served successfully sends nothing new and returns the result of the first call — the same message reference.deep-linkandhealthcarry a uniquewebhook-idthat is not a key. - The
webhook-idof your event is yours to choose: unique per event and unchanged when you deliver the same event again. Veriqa derives the keys of thesend-promptandreport-outcomean event causes from it, so a redelivered event does not ask the question twice. - A failed attempt does not bind the key: the next attempt with it is served anew.
- Retention is the operator’s choice, within two floors: no shorter than the retry budget of
Veriqa (
Http:RetryBudget), and, for a key derived from an event, no shorter than the window in which your adapter itself may deliver that event again.
Wire version and the preview status
Every envelope, in both directions, carries version — the wire version of its sender, major.minor;
the first is 1.0, with the status preview. Within one major version changes are additive only — a
new optional field, operation, enumeration value or event type — and each comes with the behavior of
a receiver that does not know it: an unknown field is ignored, an unknown operation is answered with
unsupported_operation, an unknown question variant is shown as a question without details, an
unknown event is taken by Veriqa as unrelated. Removing or renaming a field or changing its meaning
takes a new major version. Answer a call of another major version with unsupported_wire_version;
Veriqa, in turn, does not process an event of another major version and takes such an answer as
outside the contract. Every event also carries adapter_version — the semver version of your adapter.
Conformance suite
Veriqa ships an executable conformance suite that checks your adapter against the wire, whatever it is written in. It checks three things:- calls through the real wrapper — every operation, a repeat under the same key, a wrong secret and a timestamp outside the window. These calls are made by the very HTTP channel code Veriqa runs, so the suite cannot drift from Veriqa;
- malformed calls Veriqa never makes — unsigned, with an unknown field, variant or operation, of a foreign major version, an event envelope reflected at your call entry — sent by a separate sender;
- the receiver of events — it plays Veriqa for your events: checks the signature and the schema
of each, calls
send-promptbefore it answersauth_start, and checks that every event came on the route of its transaction’s tenant.
send-message.phone-number-request for OnRequest, identity.phone-automatic for Automatic. A
scenario missing from the report means its way is not declared. An event that states
phone_number_verified: true without a declared Proof: Proven fails events.all-valid.
A registration that declares the start by a code gets the scenario
flow.code-start: the suite makes up a code, has it sent to the bot of a deep link — by the platform
stub with send-text, or by you on an ACTION line — and expects auth_start_by_code with those
digits from the user of the run. With LinkCarriesTransaction: false the scenarios that start a
transaction by its link — flow.confirm, flow.decline, identity.phone-automatic — are left out of
the run: a link that only opens the chat starts nothing. The suite then does not play the confirmation
after a start by a code — its made-up code has no transaction behind it — so check that path with a
live run.
The suite is a container image, built from the root of the repository:
Veriqa__Channels__<type>__* keys Veriqa reads, plus keys of its own under Conformance__*:
ChannelType and ChannelUserId are required; Mode, Tenants and ReportPath are optional.
In
interactive and automatic the suite stands in for Veriqa: it listens on port 8080 of the image
on the same paths, /api/channels/{type}/webhook and /api/channels/{type}/webhook/{tenant}, so your
adapter changes only the base address of Veriqa it posts to. How long it waits for an event is
Conformance__EventTimeoutSeconds.
The suite prints one line per scenario — PASS <id>, FAIL <id>: <reason> or N/A <id>: <reason> —
writes a JSON report to Conformance__ReportPath, and exits with 0 (no scenario failed), 1 (at
least one failed) or 2 (the run could not be carried out; the message names what to fix). The full
list of scenarios is in the README of src/core/Veriqa.Core.ChannelAdapter.Http.Conformance.
The machine-readable schema of the wire (JSON Schema) ships with the suite: at
/schema/http-channel-wire-v1.schema.json in the image and in
src/core/Veriqa.Core.ChannelAdapter.Http.Conformance/Schema/ in the repository.
Reference adapters
Three external adapters in the repository show the whole contract on a real platform:samples/python/custom-channel-telegram/— Telegram, in Python;samples/node/custom-channel-whatsapp-twilio/— WhatsApp through Twilio, in Node;samples/node/custom-channel-whatsapp-baileys/— WhatsApp through Baileys, in Node — a working adapter for debugging, small teams and your own use; WhatsApp may ban the number.
adapter_settings read as a setting of the adapter’s own, and the protection of the adapter’s own
public entry. The Twilio adapter also answers channel_cannot_continue when the platform cannot
deliver the question; the Telegram adapter splits the updates between Veriqa and a bot of your own.
Two of them show the two ways of obtaining the phone number: the Telegram adapter on request — it
shows the request as a keyboard with a contact button and returns the second button as the text of an
unaddressed event; the Twilio adapter automatically — the number of the sender travels in the
identity of every event.
The Telegram adapter also shows the start by a code behind a switch,
CODE_START: off by default, fallback for a registration with LinkCarriesTransaction: true,
only for one with false; its CI runs the suite a second time in the only mode.
The Baileys adapter is a working one, meant for debugging WhatsApp scenarios, for small teams and for your
own use: Baileys is an unofficial WhatsApp Web client on a number of your own, and WhatsApp may ban that
number. It asks the question as a
poll and fetches the messages over its own outbound session.
Each README documents its configuration, its InboundVerification value and a manual live run.
They are samples to learn from and copy, not a catalog of community adapters.
InboundVerification of an external adapter
The rule of Declare the inbound verification level of your channel — and document it
holds for an external adapter unchanged: the platform’s traffic is verified by your adapter, so
you name the value for every inbound mode of your channel in your documentation, and the operator
declares it in the section of the channel. There is no default. The signature of the transport between
your adapter and Veriqa is always checked and is not what this value describes.
The reference adapters name theirs:
In the same documentation list the top-level keys of your
raw_metadata that carry personal data,
the native user id of the platform included: the integrator decides from that list what they may keep.