The wire contract between Veriqa and an external adapter is version
1.0 and ships with the status
preview. Until the contract is declared stable, it may still change.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.