Skip to main content
Veriqa ships eight channels, but the channel layer is an open extension point. If your users live in a messaging app that is not on the list, you write an adapter against the MIT contracts package and register it in your host. No fork of the server core, no patch, no pull request to wait for. An adapter is one class and one registration call:
Program.cs
From that single call Veriqa registers the adapter, maps its webhook endpoint with the same protections as the built-in channels, and shows the channel in the sign-in window. A complete runnable example lives in samples/dotnet/custom-channel — an adapter library plus a host that wires it up. Not writing .NET, or running the standard Veriqa image you do not rebuild? Write the adapter as a service of its own in any language and plug it in by configuration — see An HTTP channel adapter in any language.

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

Your ValidateWebhookAsync returning success tells the core only that “a check ran and passed”. What the check was, the core does not know and cannot derive: it holds no closed list of channels, and a statement the adapter makes about itself would guarantee nothing — the declaration comes from whoever carries the risk: the integrator. So your channel’s section carries the same required InboundVerification key the shipped channels do:
appsettings.json
The allowed values are signature (the body is signed with a platform key), shared_secret (a secret presented by the incoming request), outbound_fetch (there are no incoming requests, an outgoing one fetches the events) and none (authenticity is not verified); the full description is in the configuration reference.
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.
It is the adapter author’s duty to document which value their channel yields in every mode it supports. The integrator picks the value from your documentation: they do not read your validation themselves, and the core does not derive it. The example above says shared_secret because the validation shown above compares a header with a configured secret; a channel that fetches its events with an outgoing request of its own declares outbound_fetch.

ProcessInboundEventAsync — the raw body in, an intent out

Both methods receive the same envelope, ChannelInboundRequest: the HTTP method, the raw bytes of the body, the headers and the query parameters (names compared case-insensitively, repeated values joined with ", "). Bytes really are bytes: a signature is computed over exactly those, with no decoding step standing between the platform and your HMAC. Veriqa does not interpret the body format — JSON, form-encoded, XML, whatever your platform sends; parsing it with your own serializer options is your job.
The intent is the type of the result, and each one brings its own mandatory set of fields: ChannelAuthStartResult (the user reached your platform from the deep link), ChannelAuthStartByCodeResult (they sent the code shown on the sign-in page — see Start by a code), 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 in Capabilities.PhoneNumber: how it gets the number and whether the platform proves that the number belongs to the sender. What the core does with the number — when it asks, how long it waits, what the token carries — is described in Phone number.
Leaving PhoneNumber out means PhoneNumberCapability.None: the channel gives no number, and a sign-in that requires one does not offer the channel. With Automatic you put the number, in E.164, into the snapshot’s PhoneNumber and say whether it is proven in PhoneNumberVerified (left null, the claim goes out as false). With OnRequest the core calls SendMessageAsync with a message of kind PhoneNumberRequest: Text is the request, and PhoneNumberRequest carries the label of the share button (ShareButtonLabel), the second action (SecondaryAction — Skip or CancelSignIn) with its label (SecondaryButtonLabel) and the TransactionId. The texts arrive localized. Show the platform’s “share contact” button and the second button next to it: a button with a payload carries CallbackDataPrefixes.PhoneSkip or CallbackDataPrefixes.PhoneCancel followed by the transaction identifier, and a keyboard button that can only send text sends its label. Return the answers as:
  • a shared contact — ChannelPhoneSharedResult with the number in E.164 with the leading plus and the sender’s snapshot. Normalizing the number to E.164 is the adapter’s job: platforms rarely promise that format, and the core counts a number outside it as absent. E164PhoneNumber.TryNormalize from Veriqa.Core.Contracts does that. When the contact fails your platform’s ownership check, set OwnershipRejected = true: the core does not take the number and asks again. A contact names no transaction — the core finds the request by the chat;
  • the second button with a payload — ChannelPhoneStepActionResult with the parsed TransactionId and Action; an identifier that does not parse makes the event ChannelUnrelatedResult;
  • the second button as plain text — ChannelUnaddressedResult with Text: the core compares it with the label it sent into that chat.
When the sign-in ends — by its outcome or by expiring — the terminal notice of ReportOutcomeAsync carries PhoneRequestMessage — the reference your SendMessageAsync returned for the request. Take down what the request offered, its buttons or its input keyboard, by the means of your platform. The reference is absent when you returned none, and a notice of awaiting_claims never carries it: the step is still running. If you route updates with OwnsInboundEvent, override it: the default recognizes the button markers, but a shared contact carries none. Answer true for a contact event and keep the marker check with CallbackDataPrefixes.ContainsAnyMarker(request.Body.Span) — the default body of an interface member cannot be called from your class. A button that sends plain text carries no marker at all, so in this mode its press reaches your own bot: an Optional number is then skipped when its window runs out, and a Required one waits until the transaction expires. The built-in Telegram adapter has the same limitation.

Claim names — which are yours and which belong to the core

Everything you know about the user goes into the typed fields of the snapshot: name, username, locale, phone and whether it is proven (PhoneNumberVerified), email and whether it is verified (EmailVerified) — each has its own field. The core issues the claims from them; email_verified goes out only next to a non-empty email, phone_number_verified only next to a non-empty phone number. Free-form names live in AdditionalClaims, and there the three-group discipline applies (registry VeriqaClaimTypes): A key that breaks the rule is dropped with a warning in the log — authentication does not fail. So you cannot claim email or locale with a string of your own: put the value in the snapshot field. Names are compared case-insensitively — Email_Verified is the same name as email_verified, so re-casing a letter does not get around the reservation. Prefixing with your own channel type makes collisions between adapters impossible. Letter case decides whose name it is, but grants no freedom in writing it: a claim of your own is taken only in the declared spelling. With ChannelType = "acme-chat" the key ACME-CHAT_workspace is dropped — write the prefix the way the channel type is written (veriqa_channel likewise, as the registry spells it). What follows the prefix is yours: acme-chat_Workspace goes through. But two keys of your own differing only in letter case are one claim: one of them is taken, the other goes to the log as a warning. Which spelling survives is not defined: the set reaches the check frozen, and its enumeration follows the layout rather than the order you wrote it in. Send one spelling — a case-variant pair means losing one of the values.

Error codes — from the declared registry, not strings of your own

You return a failure as Result<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:
Keep the call idempotent and free of side effects. A repeated call for the same transaction returns the same target and creates no state: it issues no new secrets, writes nothing to a store and publishes no events. The core calls the method every time it shows your channel — on the sign-in page, on the page where the user of a confirmation picks a channel, in the response of the server-to-server confirmation API — and, in hop mode, again every time a person opens the hop link. A method that, say, mints a fresh one-time code on each call turns every page refresh and every scan into another live secret. The core cannot check this in your implementation — the promise is yours to keep. 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:
The core finds the transaction by the code and carries on exactly as after ChannelAuthStartResult. What belongs to whom:
  • The format of the message is yours. The core substitutes the code into MessageTemplate and shows the result; it does not interpret the wording. A template that is blank, longer than 64 characters (ChannelCodeStart.MaxMessageTemplateLength) or does not hold {code} exactly once cannot be used: a channel with LinkCarriesTransaction = false is then left out of the page, like a channel that failed to build its link, and a channel with true is shown without the block.
  • The code is the core’s. Do not normalize it and do not check its length: the page shows it in groups of three digits, the user may send it with spaces or dashes, and the core drops both. The length is one setting of the installation, Veriqa:CodeStart:CodeLength — an integer from 6 to 10, 6 by default; a value outside the range stops the host at startup. One code serves the transaction on every channel that declares the start, works once and lives exactly as long as the transaction.
  • The attempt limit is the core’s. An event with a code is charged to the same per-user budget as a start by the link, before the code is looked up. A code that names no live transaction — unknown, expired, already used — is counted like a start whose transaction cannot be served and answered with the text the registration declares for it, UnmatchedCodeReplyText: one text for every reason, so the answer does not tell whether the code ever existed. A registration that declares none answers with StaleLinkReplyText, or with silence when it declares neither.
  • A message with a code carries no Veriqa marker, so the default OwnsInboundEvent does not claim it. Override the member if you route updates by it.
A code is shared by the whole installation: it is unique among all live codes, whichever tenant the sign-in belongs to, and it is found whichever webhook address the message arrives at — with the tenant’s segment or without it. All tenants draw from one space of codes, so an installation with many sign-ins in progress at the same time should raise Veriqa:CodeStart:CodeLength.
Of the shipped channels, Instagram and Messenger declare the fallback: their link carries the transaction, and the platform does not always deliver it. The HTTP channel declares the start by configuration — see The start by a code. Both modes can be tried in samples/dotnet/custom-channel: its README walks through the start by code (AcmeChat:StartByCode) and the fallback (AcmeChat:CodeFallback).

3. Name your channel

The channel type is a contract, not a display string. It must match:
A lowercase URL-safe token starting with a Latin letter — kakaotalk, acme-chat, zalo. The value is matched ordinally against acr_values and becomes a URL path segment, so anything else (KakaoTalk, my_channel, an empty string, a value with a trailing newline read from configuration) fails the startup with a message naming the allowed format. The same fail-fast applies to a duplicate registration of a type — which covers a type a shipped channel already claimed, because the shipped channels are registered through this very AddChannel and live in the same registry — and to the type passed to AddChannel differing from the one your adapter returns from ChannelType — the two must be equal, because everything downstream matches the channel by the adapter’s ChannelType. That last check runs on host start, not on the webhook mapping, so an outbound/polling-only channel registered with mapWebhook: false is diagnosed just as strictly.
That last check has one boundary: a cross-wired swap — one channel’s adapter registered under another channel’s type — is caught by cross-checking the adapters’ runtime types. If your host layers a sweeping decorator over every IChannelAdapter (Scrutor Decorate<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 maps
with the same rate-limit policy and the same 1 MB body limit as the built-in channel routes — the generic route is not a way around the protections applied to telegram or whatsapp. Answer semantics: 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:
What makes the handshake genuine is yours: the GET is validated by the same ValidateWebhookAsync as a POST, so the verify token, the spelling it arrives under and the mode check are your adapter’s business. Veriqa only knows which query parameter holds the value it has to echo. The route is fail-closed, exactly like the POST one: Declare nothing and the GET route is not mapped at all — the path answers 405, which is the behavior a channel whose platform has no handshake wants.

5. Show the channel in the sign-in window

A registered channel already appears as a button — by default labeled with its raw channel type and without an icon. Implement the optional IChannelDisplayMetadata to give it a name and a glyph:
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:
The core puts the value, as it is, into the texts that tell the user which chat to look at: the caption over the code and the line that tells the user to confirm in the chat — the hop launch page shows it once it has taken the user into the channel, and the sign-in window shows it once your channel has answered the start of a sign-in or a confirmation. The method is called under the tenant of the transaction, as GetDeepLinkAsync is. null, a blank value, a value longer than 32 characters or an exception produces the same texts, worded without a name — the channel name is never put in its place. A configured name comes first. The BotName key in the section of the channel 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:
Override the token itself — it is the supported point of control over the size. A rule such as #veriqa-panel-acme-chat .veriqa-qr img { width: … } gets the same result by bypassing the token and is therefore forbidden: the next change to the window layout will drift it out of step with the other sizes. You may not shrink the QR below either of two thresholds:
  • 200×200px — the minimum physical target size for a camera;
  • 2.86px per module — the density: box size ÷ the QR's total module count including the quiet zone.
There are two criteria and meeting one does not substitute for the other: a long deep link is encoded by a higher QR version, so at the same box size the module shrinks past readability even though the 200px rule is formally satisfied. The box size is only half the picture; the other half is the resolution of the source PNG — its pixels per module. There is a single rule, and you have to check it right away, not only if you enlarged the box:
Otherwise the image is stretched and its module grid blurs. Why this applies to the shipped 220px box too. The PixelsPerModule default of 6 is sized for the deep links the core ships: the most compact of those QRs is 45 modules, and 45 × 6 = 270px covers the box. In a 220px box the default suffices from ⌈220 ÷ 6⌉ = 37 modules up. A QR only gets sparser than that from a very short link: acme://c/8f3k9d — 33 modules, 33 × 6 = 198px, already stretched in the shipped box. The link from §2, the one that carries the transaction id (43 characters), does not fall into that case — it comes out at 45 modules and the default covers it. So you need the entry below only if you keep a short code of your own with your own mapping to the transaction. The host starts either way: the value is inside the allowed range, and your QR’s module count is unknown to the core — you are the only one who can check the rule. If that is your case, the fix is an entry for your own channel:
appsettings.json
The 8 here is ⌈220 ÷ 29⌉: 29 modules is the most compact QR that can exist at all (version 1 with the quiet zone), so 8 covers the shipped box whatever the length of your deep link. If you enlarge the box, recompute this value with the same formula. The allowed range is 5…35. The upper bound is that same formula applied to a 1000px box and the same 29 modules, so the range covers any box that fits on a screen, for any channel. A value outside it stops the host at startup — but 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
Your channel’s configuration section belongs to you: Veriqa neither knows nor binds it. Bind it in the host and pass the result to your adapter — and keep the secrets in user-secrets or a secret store, not in appsettings.json. Use AddChannel<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 from IPollingTenantSource. Resolve it from the container and ask it which tenants your channel is active for; a null element is the default implicit tenant of a self-hosted installation. It has one precondition: the set is built from the core-level declarations of the registered channels, and AddChannel does not register one — it registers the adapter and its route. So declare your channel in the host, next to the AddChannel call:
Program.cs
Both facts are read through delegates, and the shipped channels close them over exactly these two accessors. isEnabled is asked again every time the available set is built, so it has to read a source that survives a re-read of the configuration: a monitor, not an options object bound once — that one would freeze a deployment-side edit out of the answer. usesPolling is a startup decision, and IOptions is what the shipped channels read it through. Both need the section in the container (builder.Services.Configure<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:
A health probe of your own runs outside any request and reports on the installation itself, so it states the default tenant explicitly — ChannelTenantContext.BeginScope(null) — rather than by omission: a credential read with no scope open at all is indistinguishable from a path that forgot to state its tenant, and nothing downstream can tell the two apart for you. On the webhook you do not open a scope. The tenant of an incoming request is already stated — by the route (/api/channels/{channelType}/webhook/{tenant}), before your adapter is called. One of your own opened there would repoint every credential read below it, the core’s included, at a tenant of your choosing instead of the one the host named. You resolve your own credentials; you do not receive them. Declare your key and resolve it through the public IConfigurationResolver with ResolutionContext.ForTenant(tenantId) — the same canonical resolver and the same level order the shipped channels go through. The public client factory IChannelClientFactory is not needed for that: it builds and caches a client per (channel, tenant), which is a different job. How a key of your own is declared — the catalog, the address on each level, the registration — is in Declare your own configuration keys.

An HTTP channel adapter in any language

Everything above is the .NET path: your adapter runs inside the host. The second path needs no .NET and no host code at all. The HTTP channel is an adapter shipped with Veriqa that stands in for yours inside the host and forwards the work over HTTP to an external adapter — a service you write in any language and run next to Veriqa or inside your own perimeter. The channel is added by configuration.
The wire contract between Veriqa and an external adapter is version 1.0 and ships with the status preview. Declaring it stable is a separate decision; until then a revision of the contract may still change it.

How the traffic flows

  • The platform talks to your adapter, not to Veriqa. Your adapter receives the platform’s webhooks or polls it, verifies them and answers the platform’s challenge handshake itself. Veriqa maps no GET route for an HTTP channel.
  • Your adapter posts events to Veriqa on the routes every channel has, /api/channels/{channelType}/webhook or /api/channels/{channelType}/webhook/{tenant}. An event of a transaction goes to the route of that transaction’s tenant — the one the tenant field of the deep-link and send-prompt calls of that transaction carried; null means the route without a tenant segment. Veriqa does not tell you the tenant again: keep the mapping “transaction → tenant” yourself.
  • Your public entry is yours to protect. Veriqa’s rate limit guards its own routes; the entry your adapter opens to the platform needs protection against junk traffic of its own.
  • Veriqa calls your adapter at one address, Http:BaseUrl, for everything it needs from the channel.
  • The platform credentials stay with your adapter. Veriqa never asks for them and never stores them.
  • One registration is one external adapter: one address, one transport secret, one set of channel facts. Without Http:Tenant it serves every tenant of the installation. A tenant that brings an implementation of its own gets a registration of its own, under its own name (for example shop-a-zalo), with Http:Tenant naming that tenant.
  • The standard Veriqa image always registers HTTP channels: a channel is added with environment variables and a restart of the container, without rebuilding the image. An embedded host references the Veriqa.Core.ChannelAdapter.Http package and adds one call:
Program.cs

Configuration

Every section Veriqa:Channels:{channelType} with an Http subsection becomes one HTTP channel under the name of the section:
appsettings.json
The same channel in environment variables, with the transport secret — keep it there or in a secret store, not in appsettings.json. A channel type with a dash cannot be exported from a shell, but the environment: of a compose file and docker run -e take it:
What to know about the keys (the full list is in the configuration reference):
  • The channel facts and the button are stated by the operator, with Http:Capabilities:* (one key per property of ChannelCapabilities), Http:DisplayName and Http:IconSvgPath. There is no network manifest: Veriqa does not ask your adapter what it can do. A fact left unstated takes the safe default of ChannelCapabilities — every boolean false, plain_text as the only content kind, no emoji rule of the channel’s own (RendersEmoji unset), no phone number (The phone number).
  • Http:Tenant names the tenant that owns the registration. It serves that tenant alone; for every other tenant the channel does not exist — not on the inbound route, not for events, not in outbound calls. Unset, the registration serves every tenant of the installation.
  • Http:AdapterSettings is an opaque string, up to 8192 characters, that Veriqa forwards verbatim with every call and never reads. It has a core level and a tenant level; the value of a tenant replaces the core value entirely.
  • Http:AllowInsecureHttp admits an http base URL. Use it only for an isolated internal network (a neighboring container); Veriqa writes a warning at startup.
  • Every Http:* value is fixed at startup, except Http:AdapterSettings, which is resolved per call. Changing the others takes a restart.
  • The name of the channel follows the format of Name your channel, 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 http base URL without Http:AllowInsecureHttp, a missing Http:TransportSecret, a secret not in the whsec_ form, a secret shared by two registrations, a forbidden deep-link scheme, a timeout not in the hh:mm:ss form, Http:AdapterSettings longer than 8192 characters, a reserved name or one another channel of the host already has. An empty Http:Tenant is an error too: to serve every tenant, remove the key.
Unlike an AddChannel channel (the note in §2), InboundVerification is checked at startup for an HTTP channel: the registration declares the channel to the core, so an enabled HTTP channel without a value stops the host.

The phone number

A channel that can give the user’s phone number says so in Http:Capabilities:PhoneNumber — the same fact a built-in channel declares in code (see Phone number on request and Phone number). Left unstated, the channel gives no number and is not offered to a sign-in that requires one.
appsettings.json
The names are matched without regard to case. Five mistakes stop the host from starting: PhoneNumber written as a single value instead of a section, a value outside the names above, Acquisition written as a single value instead of a list, Proof: Proven with no Acquisition, and OnRequest without phone_number_request in SupportedMessageKinds — the request would never reach your adapter. Proof is the ceiling of what your events may state. identity.phone_number_verified: true of an event is taken only from a registration that declares Proven; from any other, Veriqa lowers it to false and writes a warning. false and an absent field are never raised: declaring Proven does not make a number proven that your adapter said nothing about.

The start by a code

A channel whose link cannot carry the transaction, or whose platform does not always deliver it, declares the start by a code in Http:Capabilities:CodeStart — the same two members a built-in channel sets in code. The core shows the code on its pages, the user sends it to your bot, and your adapter posts it with the event auth_start_by_code (see The wire contract).
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 signed POST of a JSON envelope to Http:BaseUrl: version, operation, channel_type (one entry may serve several registrations), tenant (or null), adapter_settings (or null) and payload. The answer is 200 with an envelope of version and exactly one of result or error (code and message). 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 error is written to the log and decides nothing else, with one exception: channel_cannot_continue in the answer to send-prompt ends the transaction in failure right away. Any other error acts by its operation, and the call is not repeated after it:
    • send-prompt — the transaction stays open and waits for a new auth_start from the user or the end of its lifetime;
    • deep-link — the user gets no way into your channel: the sign-in page is rendered without it;
    • send-message, report-outcome — the refusal is logged and counted in the metrics, nothing more.
  • deep-link is idempotent: a repeated call for the same transaction returns the same url and creates no new state.
  • Answers are bounded. An answer is read up to 1 MB and refused past it, redirects are not followed, and a deep link is accepted only as an absolute URI with the https scheme or a scheme listed in Http:DeepLinkSchemes, no longer than 2048 characters.
  • Timeouts and retries. One attempt waits Http:OperationTimeout (10 s by default); deep-link waits Http:DeepLinkTimeout (2 s) because it stands on the path of the sign-in page. send-prompt, send-message and report-outcome are retried on a transport failure — no connection, a timeout, a 5xx, a 429 — up to three times, all attempts within Http:RetryBudget (20 s). Nothing else is retried: a 200 with error, a 3xx, any other 4xx, an oversized or malformed answer. deep-link and health are attempted once.
  • The second action of a phone number request comes back in either of two ways, whichever your platform allows: the phone_step_action event, or an unaddressed event whose text equals the caption of the second button. An action Veriqa does not know makes the event unrelated. The share button itself is answered with phone_shared.
  • The phone number of an identity. identity.phone_number of any event is taken as the user’s number — the automatic way — and identity.phone_number_verified: true only up to the Proof the registration declares (see The phone number).
  • auth_start_by_code goes on the route your bot’s other events use. It names no transaction, so the rule of the transaction’s tenant does not apply to it: Veriqa looks the code up across the whole installation, whichever route the event came on, and takes the tenant from the transaction it finds.
  • Refuse an event envelope on your call entry. A body with event instead of operation that arrives at Http:BaseUrl is an event reflected back at you; answer it with anything but a 200 with result.
  • Serve calls while your own request is open. On auth_start Veriqa sends send-prompt to your adapter before it answers your POST of the event. An adapter that cannot take calls concurrently with its own requests to Veriqa times the prompt out, and the transaction waits for its lifetime.

The signature

Both directions are signed with the transport secret of the registration by the Standard Webhooks scheme v1 (HMAC-SHA256) — every language has a library for it. The headers are webhook-id, webhook-timestamp and webhook-signature, and a timestamp is accepted within 5 minutes either way of the receiver’s clock.
  • Rotation without downtime. Put the new secret in Http:TransportSecret and the old one in Http:PreviousTransportSecret: the sender then signs with both, and the receiver accepts either. Veriqa writes a warning at startup until the previous secret is removed.
  • Answers are not signed: their authenticity rests on the connection Veriqa opened to the configured address — which is why https is the default.
  • A wrong signature is 403, without an envelope and without any effect, in both directions: Veriqa answers your unsigned or mis-signed event that way, and your adapter must answer such a call the same way.
  • Every registration has a secret of its own. Two registrations sharing one would let one pass as the other, so the host refuses to start.

Idempotency

  • The webhook-id of send-prompt, send-message and report-outcome is the idempotency key. A repeat with a key you have already served successfully sends nothing new and returns the result of the first call — the same message reference. deep-link and health carry a unique webhook-id that is not a key.
  • The webhook-id of your event is yours to choose: unique per event and unchanged when you deliver the same event again. Veriqa derives the keys of the send-prompt and report-outcome an event causes from it, so a redelivered event does not ask the question twice.
  • A failed attempt does not bind the key: the next attempt with it is served anew.
  • Retention is the operator’s choice, within two floors: no shorter than the retry budget of Veriqa (Http:RetryBudget), and, for a key derived from an event, no shorter than the window in which your adapter itself may deliver that event again.

Wire version and the preview status

Every envelope, in both directions, carries version — the wire version of its sender, major.minor; the first is 1.0, with the status preview. Within one major version changes are additive only — a new optional field, operation, enumeration value or event type — and each comes with the behavior of a receiver that does not know it: an unknown field is ignored, an unknown operation is answered with unsupported_operation, an unknown question variant is shown as a question without details, an unknown event is taken by Veriqa as unrelated. Removing or renaming a field or changing its meaning takes a new major version. Answer a call of another major version with unsupported_wire_version; Veriqa, in turn, does not process an event of another major version and takes such an answer as outside the contract. Every event also carries adapter_version — the semver version of your adapter.

Conformance suite

Veriqa ships an executable conformance suite that checks your adapter against the wire, whatever it is written in. It checks three things:
  • calls through the real wrapper — every operation, a repeat under the same key, a wrong secret and a timestamp outside the window. These calls are made by the very HTTP channel code Veriqa runs, so the suite cannot drift from Veriqa;
  • malformed calls Veriqa never makes — unsigned, with an unknown field, variant or operation, of a foreign major version, an event envelope reflected at your call entry — sent by a separate sender;
  • the receiver of events — it plays Veriqa for your events: checks the signature and the schema of each, calls send-prompt before it answers auth_start, and checks that every event came on the route of its transaction’s tenant.
Two scenarios check the phone number, and each joins a run only when the registration declares its way: send-message.phone-number-request for OnRequest, identity.phone-automatic for Automatic. A scenario missing from the report means its way is not declared. An event that states phone_number_verified: true without a declared Proof: Proven fails events.all-valid. A registration that declares the start by a code 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:
Run it next to your adapter with the configuration of your channel in the same 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.
Each runs next to Veriqa from its own folder:
Between them they show every operation and every event of their channel, the signature in both directions, idempotency (a repeat sends no second message), credentials per tenant, adapter_settings read as a setting of the adapter’s own, and the protection of the adapter’s own public entry. The Twilio adapter also answers channel_cannot_continue when the platform cannot deliver the question; the Telegram adapter splits the updates between Veriqa and a bot of your own. Two of them show the two ways of obtaining the phone number: the Telegram adapter on request — it shows the request as a keyboard with a contact button and returns the second button as the text of an unaddressed event; the Twilio adapter automatically — the number of the sender travels in the identity of every event. The Telegram adapter also shows the start by a code 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.