Identity Verification¶
Identity resolution says whose an address is: a number that resolves to Alice says the phone is Alice's. It does not say that Alice is the one typing. Verification says the person writing has proven who they are. A service that answers account questions over SMS, WhatsApp or Telegram needs both.
RoomKit keeps the state and its lifecycle, labels every message, holds an unverified one from the agents and releases it once verified (RFC §11.7). How a person proves who they are — a page behind a link, a login, a code replied in the conversation — stays yours.
Verification applies to an identified sender only. Asking an unknown sender
who they are is identification (IdentityHookResult.challenge()), not this.
Quick Start¶
from roomkit import HookResult, HookTrigger, RoomKit, VerificationPolicy
kit = RoomKit(
identity_resolver=MyResolver(),
verification_policy=VerificationPolicy(), # turns verification on
)
@kit.hook(HookTrigger.ON_VERIFICATION_REQUIRED)
async def ask_to_verify(event, ctx) -> HookResult:
state = await kit.sender_verification(event, ctx)
if state is not None and state.request_id is None: # no link open
request, secret = await kit.request_verification(
state.identity_id,
room_id=event.room_id,
channel_id=event.source.channel_id,
window_start=event.created_at,
)
send_link_later(event.room_id, f"https://bank.example/verify/{request.id}/{secret}")
return HookResult.allow() # held until verified
Your page checks the person its own way, then tells RoomKit:
from roomkit import VerificationRefusedError, VerificationRequestStatus
async def verify_page(request_id: str, secret: str, pin: str) -> str:
request = await kit.store.get_verification_request(request_id)
if request is None or request.status != VerificationRequestStatus.OPEN:
return "This link is no longer valid."
try:
if not pin_matches(request.identity_id, pin):
counted = await kit.record_failed_attempt(request_id) # bounded like a wrong secret
if counted.status == VerificationRequestStatus.FAILED:
return "Verification failed. Text us again to get a new link."
return "Wrong PIN."
await kit.complete_verification(request_id, secret)
except VerificationRefusedError:
return "This link is no longer valid."
return "You're verified."
Once verified, what the person wrote while the request was open is released to the room's agents, which answer it.
The State¶
A verification belongs to the identity, in an organization: verified once,
the person is verified on every channel and in every room their addresses
reach. verified_via names the channel they proved it on, for an integrator
that wants a channel of its own.
VerificationStatus |
Meaning |
|---|---|
UNVERIFIED |
Never verified, or a request failed |
PENDING |
A request is open |
VERIFIED |
Proven, until expires_at |
EXPIRED |
Its time ran out |
REVOKED |
Ended by revoke_verification() |
kit.verification_of(identity_id) reads it. An identity asked again while
verified stays VERIFIED until the new request completes or fails.
Requests¶
request_verification(identity_id, room_id=, channel_id=, method=, window_start=)
opens a request and returns it with its secret, once. RoomKit keeps only its
scrypt hash.
method |
Secret | Lifetime |
|---|---|---|
"link" (default) |
256 bits, URL-safe: put it in your link | request_ttl (600 s) |
"code" |
code_length digits (6): the person replies with it |
code_request_ttl (300 s) |
Opening a request cancels the identity's earlier open one: an old link stops
working. A request takes its room's organization; a caller passing another
organization_id gets RoomNotFoundError.
complete_verification(request_id, secret) refuses a request that is unknown,
no longer open, expired, or given a wrong secret, with
VerificationRefusedError.reason: not_found, not_open, expired,
mismatch, failed. A mismatch counts as an attempt, and so does your own
check's failure reported with record_failed_attempt(request_id); the last
allowed attempt fails the request. A request that fails while the identity is
verified ends that verification too: whoever could not prove it is not let on
as the person.
The message carrying the secret
A message you send through the room (send_event) is stored like any
other: the link or code it carries is readable by whoever reads the
timeline, an advisor's console included. To keep the secret out of the
store, send it through the channel's provider directly. A link is a bearer
secret either way: forwarded, it verifies whoever opens it and passes your
page's check, so put a check (a PIN, a login) behind it.
On Every Message¶
With a verification_policy, the inbound pipeline stamps
event.source.verification on every message of an identified sender: hooks,
routers, user interfaces and context builders read the same fact. While the
identity is not verified, the message fires ON_VERIFICATION_REQUIRED (sync),
whose hooks decide:
| Hook returns | The message |
|---|---|
HookResult.allow() (or no hook) |
Held: stored and shown to the room's transports (the advisor sees it), read by no agent |
HookResult.allow_unverified() |
Passes, labelled: for a general question |
HookResult.block(reason) |
Blocked, not stored |
A hook that only watches returns allow(): it never lets a message through by
accident. Holding only narrows what a message reached; it never adds a channel.
An edit or a deletion from an unverified sender is refused unless a hook passes
it. VerificationPolicy(hold_unverified=False) only labels.
kit.sender_verification(event, ctx) gives the hook the sender's verification
in the room's organization: identity_id, status, and request_id when a
request is open. It works on the sender's first message too, before they are a
participant of the room.
Hooks run under the room lock
Send the link after the hook returns (a background task): send_event
from inside a sync hook waits for the lock the hook is holding.
A sender who writes again while a request is open meets silence unless your hook answers. Reminding them means opening a new request: a secret is never kept to be sent again.
Codes in the Conversation¶
With method="code", a reply from the request's identity, in its room, whose
whole text is a code of the right length is an attempt: it completes the
request or counts against it, and it is consumed — never stored, never
broadcast, read by no agent. Any other message is an ordinary one.
Release¶
When a request completes, the held messages its sender wrote while it was open
are released (release_on_verify=True): each gets back the visibility it had,
through the store (ON_EVENT_UPDATED), and the room's agents answer the last
one as any message of its author, the earlier ones as its context. Nothing is
copied, and a released message is the author's own turn, never an instruction.
Only the request's window is released: from window_start (pass the held
message's created_at) to the completion, at most release_max_messages
within release_max_age. What was written before — during a request that
failed, before a verification was revoked — may not be the verified person's,
and stays held. Only what the pipeline held is released: the held mark is the
runtime's, and the pipeline removes one a sender's metadata carries.
The End¶
| Hook (async) | Fires when | event.content.data |
|---|---|---|
ON_VERIFICATION_COMPLETED |
A request completes | identity_id, organization_id, request_id, channel_id, method, expires_at |
ON_VERIFICATION_ENDED |
A verification or a request ends | identity_id, organization_id, request_id, channel_id, reason, ended |
ended says what ended: "verification" when the verification in force ended
(expired, revoked, a request failed while it held), "request" when a request
alone did (a link left unanswered, a failure while pending). Both hooks fire in
the room the request was opened in; the end of a verification in the room it
was proven in.
A verification ends when it expires, by a timer, not on the person's next message, and so does a request left unanswered: once it has, the identity has no request open, and its next message asks for a new one.
@kit.hook(HookTrigger.ON_VERIFICATION_ENDED, execution=HookExecution.ASYNC)
async def ended(event, ctx) -> None:
data = event.content.data
if data["ended"] == "verification" and data["reason"] == "expired":
await kit.send_event(
event.room_id, "bank", TextContent(body="Your verification has expired."),
visibility=data["channel_id"],
)
kit.revoke_verification(identity_id, reason="advisor") ends one before its
time and cancels any open request, so a link sent before the revocation never
verifies.
The timers live in the process. async with RoomKit(...) (or
kit.check_verifications()) ends what expired while nothing ran and re-arms
the rest from the store; kit.close() stops them. A request whose timer did
not run here (the process restarted) reads as ended once past its expiry, and
the next request announces its end.
Policy¶
VerificationPolicy field |
Default | Effect |
|---|---|---|
ttl |
900 | Seconds a verification stays in force |
request_ttl |
600 | Seconds a link request may be completed in |
code_request_ttl |
300 | Seconds a code request may be completed in |
max_attempts |
3 | Wrong secrets or failed checks before a request fails |
code_length |
6 | Digits of a code (6 to 12) |
hold_unverified |
True |
Hold an unverified sender's message; False only labels |
release_on_verify |
True |
Release held messages once verified |
release_max_messages |
20 | At most this many are released, the latest |
release_max_age |
3600 | Older held messages are not released |
Realtime Voice¶
Labelling and holding act on messages. In a speech-to-speech session
(RealtimeVoiceChannel) the model hears the person before any message
exists, and the transcript is recorded afterwards: nothing is labelled or
held there. Gate the session yourself: start it, or give it the instructions
and tools that reach an account, only once verification_of(...) reads
VERIFIED, and end or reconfigure it on ON_VERIFICATION_ENDED.
Organizations¶
Verification state is scoped to the organization, as identities are. A message is labelled by the verification of its room's organization: one proven to organization A never verifies the person in organization B's conversation.
Storage¶
The in-memory, SQLite (schema v5, migrated in place) and PostgreSQL stores keep verifications and requests. A store of your own keeps working without them: the operations refuse by name until you implement them.
Example¶
examples/identity_verification.py
runs with mock providers: a held question answered once Alice passes the PIN
page, a code replied in the conversation, and an advisor ending a verification.
See Also¶
- Identity Resolution — who an address is
- RFC §11.7 — the normative rules