anyreach.identify(), a caller ID, an email sender, an order number the customer types. None of those is proof. Anyone can call identify() with someone else’s email, and the model will believe whatever the customer says.
Verified identity turns a claim into a record the agent can rely on. You write the check as a verifier workflow (a Shopify login signature, an order number plus delivery ZIP, a caller lookup), and the platform owns everything around it:
- A protected record. Only a verifier can write it. The browser, the model and ordinary workflow output cannot.
- Triggers. Verifiers run when your page identifies a visitor, when a call, email or SMS conversation starts, or when the model calls one as a tool mid-conversation.
- Tool gating. An account tool declares that it needs a verified identity, and the platform refuses to run it without one.
- Identity-bound history. In the web widget, a verified conversation follows the customer across devices and disappears when they sign out.
The record
Each conversation holds at most one entry persubject_type. A subject_type is your name for a kind of identity (shopify_customer, frontline_worker) and fixes what subject means, so every verifier for that type (login, order + ZIP) produces the same key for your tools to look up by. A newer verification replaces an older one of the same type: found as A by a caller lookup, then proved B’s order, and B is the verified shopify_customer. A page sign-in is the exception: while a widget chat holds one, no verifier tool of its type runs in it, so a signed-in customer’s chat never becomes someone else’s (see Verifier tools). Newer means proved later, not finished later: an automatic verifier that was already running when the customer proved something in the conversation (a slow caller lookup, a sign-in refresh) leaves that proof in place.
identifiersanswers “who is this in another system”.emailandphoneare reserved:emailis lower-cased and dropped unless it contains@,phonekeeps only digits and+and is dropped under seven digits. A verified email or phone becomes the first identity used for human handoff (Gladly, for example), ahead of anything the customer typed. Other keys are yours.attributesis opaque to the platform: validated against your schema, never interpreted.- Expiry is not deletion. Past
expires_atthe entry stays on the conversation (for history and analytics), but tools stop treating it as verified.
Where to read it
These paths work in prompts, tool templates and workflow input. The platform adds one note of its own, when a web-widget visitor signs in during a chat (see Signing in mid-chat); nothing else is injected into the prompt automatically, so reference the record where you want it:
Writing a verifier workflow
A verifier is an ordinary workflow whose output follows a small contract. The platform makes that output trustworthy; making the check sound is the workflow’s job.What a verifier can trust
Anyone can send anything. A verifier that “looks upcustom_metadata.email and verifies if the customer exists”, or that trusts a match: true the model passed as an argument, verifies nobody, and the platform cannot tell. The proof must be something the visitor cannot produce: a signature made with a secret on your server, a lookup in a system the visitor cannot write to, a code sent to the address on file.
On email, the verifier’s input also carries
conversation_metadata.active_sender, the From address of the messages the current turn answers, and conversation_metadata.email_auth, the spf, dkim and dmarc verdicts the receiving mail server computed for them (pass, fail, gray, processing_failed or unknown). dmarc is pass only when every one of those messages passed. Both are null when the turn has no single sender. See On email.
Output
The workflow’s Output step returns one of three shapes:subject must be a non-empty string or number, method a non-empty string, and subject_type, when present, must match the verifier’s. Anything else, including a verified_identity that fails your output_schema, is recorded as invalid_output and nothing is stored: output is never partially applied. Any verified_by, verified_at, expires_at or level the workflow returns is ignored, and a verifier’s custom_metadata output is never saved to the conversation.
Build rules
- Saved or inline. A verifier can be a saved, published workflow (referenced by
workflow_idandworkflow_version) or an inline definition; both read workflow credentials, which a verifier almost always needs (a signing secret, an Admin API token). A saved workflow keeps its lockout count when you edit it: an inline one is keyed by its definition, so editing it starts the failed-attempt count afresh. - Pick the input step. Automatic verifiers (see below) receive
conversation_metadata,custom_metadata,user_contextand an emptyarguments({}), so they can start from a Pre Call or an Ongoing Conversation input step. A verifier attached as a tool receives the tool’s realargumentsand needs an Ongoing Conversation input step. One workflow on an Ongoing Conversation step can therefore serve both. - Keep secrets in credentials and compare signatures with
$hmacVerify, which compares in constant time. Never compare signatures with=. - Test it from the workflow editor with a sample input before attaching it. See Test without a theme deploy.
Verifier settings
Marking a workflow action withverifies_identity is what makes it a verifier. Without it, a workflow’s output can never write the record.
Set
lockout to null for a verifier that delegates to a system with its own limit (Twilio Verify locks after five checks), where a failure is not a guess (a caller lookup that finds nobody), or where you prefer a handoff to a lock.
When verifiers run
A verifier that runs out of time counts as unverified; it never blocks or ends the conversation. On email no one is waiting, so both timings run before a reply is drafted, on the first mail from the thread’s owner that passed DMARC (see On email). Widget conversations never use the
start trigger: the page’s identify() is their entry point.
Automatic verifiers
identity_verifiers on the agent config is a list of verifier workflows the platform runs for you: at the widget session, and at conversation start on telephone, email and SMS. In the agent editor it is the Identity verifiers list under Before & After, next to the pre-conversation workflow.
Each entry is a workflow action (workflow_id, workflow_version, initial_step), with these fields doing the work:
The
condition is a Jinja expression evaluated before any workflow starts, so a visit that fails it costs nothing. It can read conversation_metadata.* (including channel, direction and widget_id), user_context.* and custom_metadata.*:
Every matching verifier runs in parallel, and the results are written once, in list order: if two verifiers prove the same
subject_type, the one listed first wins, not the one that finished first. An entry of that type proved in the conversation after they started (by a verifier tool, say) is newer and stays.
A verifier is not the pre-conversation workflow. That one decides how a conversation goes (routing, context, rejecting it) and its inputs are forgeable. A verifier only answers who this is: it never rejects a conversation, and it writes nothing but the protected record.
Verifier tools (mid-conversation)
Any verifier can also be attached as an ordinary tool: add the workflow to a tool’s actions and setverifies_identity on that action. In the agent editor, open the tool under Tools & Actions and turn on Verifies identity in its Identity settings; it is offered on any tool with a Run a workflow action, built inline or saved. Inline and saved workflows can both read credentials. The model calls it with what the customer typed, for example an order number and a delivery ZIP. Here the workflow looks the order up with an Admin API credential, compares the ZIP, and returns the order’s customer id as subject with method: "order_zip".
{"verified": true, "subject_type": …, "method": …} or {"verified": false, "reason": …}, where reason is your verifier’s reason, the outcome (locked, timeout, error) when it gave none, or ambiguous_sender or sender_not_owner when an email turn has no single sender, or one other than the thread’s owner, and the verifier did not run. A call made while the verifier’s other checks hold every lockout slot gets "reason": "busy" and a message telling the model to wait for the check already running; it runs nothing.
A call in a widget chat that holds an unexpired page sign-in of the verifier’s subject_type gets "reason": "signed_in" and a message saying the customer is already verified as the signed-in customer; it runs nothing and takes no lockout slot. The chat belongs to the signed-in customer, the same rule that stops a sign-in taking a chat verified in it as someone else: signed in as A, a customer who gives B’s order number and ZIP is still A, and to act for B they sign in as B.
Gating tools
Setrequires_verified_identity on a tool and the platform checks for a verified, unexpired entry before any of the tool’s actions run. In the agent editor, turn on Requires verified identity in the tool’s Identity settings under Tools & Actions. Any tool can be gated, whatever its actions.
- Found: the actions run, and their workflows receive the entry as
conversation_metadata.gated_identity. Take the customer id fromgated_identity.subject, never from the tool’s arguments. - A verifier still running (a background verifier on a call, the widget refreshing its session): the gate waits up to 5 seconds for it, then treats the conversation as unverified. A verifier that started more than 30 seconds ago is not waited for.
- Not found: nothing runs and the model gets
available_verifiers lists the agent’s verifier tools for that subject_type, minus any whose lockout slots are all taken (locked, or busy with checks still running), and none while the chat holds a page sign-in of that type: they would not run, so a tool whose methods leave out the sign-in’s method stays refused in that chat. The model can offer one, then retry. unverified_result replaces that text with your own, rendered like a tool’s output template, with subject_type, available_verifiers and methods in scope. It shapes what the model is told; it cannot make the tool run.
methods stands in for assurance levels: a caller-ID match can be enough to say whether an order shipped, and not enough to cancel it.
actions_on_error does not run.
On email
Anyone can put any address in an email’s From. The receiving mail server checks it: a DMARCpass means the sender’s domain vouched for the message. That verdict comes from the server, not from a header, so the sender cannot forge it.
An email thread belongs to its owner: the sender of its first message or, on a thread your agent started, the address it first emailed. Only the owner’s mail can carry an email identity.
Each reply answers the latest batch of mail the conversation recorded (mail that arrives within a couple of seconds, or while the agent is still answering, is recorded together), and that batch is judged on its own messages, never on whoever wrote to the thread since:
Three rules follow from that:
- Identity taken from the sender address needs a DMARC pass. A start verifier can only rely on the address, so on email it runs on the owner’s first message that passed, whenever that arrives: after the agent has already replied too, as on a thread your agent started. Each turn before that is recorded as
no_evidencewith the reasonsender_unauthenticated,sender_not_ownerorambiguous_sender. - In-chat proofs work on any of the owner’s messages, but carry over only with a DMARC pass. An order number plus ZIP proves what the customer typed, so a verifier tool runs whether or not the owner’s mail passed. The entry records the address and the batch it was proved in. A later email uses it only when it passed DMARC; otherwise the customer proves it again.
- Replies on a verified thread go only to its owner. Once someone was verified on a thread over email, replies (the agent’s, and a human agent’s from your helpdesk) go to the owner and copy only the people the owner copied on its last message that passed DMARC (nobody, if it never sent one). A Reply-To, a Cc another message added, and every other sender are ignored. A thread nobody was verified on keeps the usual reply-all routing.
Human handoff
When a conversation goes to a person in your helpdesk (Gladly, for example), a verified email or phone is what the helpdesk customer is matched on. On an agent with identity verifiers, verifier tools or gated tools, the internal note the agent sees, on a live handoff and on an idle-archive record alike, states on the line directly under its title whether Anyreach verified that identity:
On such an agent, everything that came from
custom_metadata (what the visitor’s browser sent, but also campaign lead data, workflow output and values set through the API), including a name, email or phone read from it, is listed under Other details (not verified by Anyreach):, one line per value. None of it is verified, whatever it says. An agent that verifies no one gets neither the line nor the heading: its note lists those details where it always has.
Every value is one line of at most 1,000 characters with its spaces collapsed, and each line under that heading is indented, so nothing a visitor sends can start a line that passes for the status. A message the customer sends during the handoff reaches the agent with any line that shows the prefix of Anyreach’s automated messages marked Customer wrote:.
If Gladly will not take the note through the API, the context goes into the conversation as an automated message instead, without the status line: a visitor can post messages there too. What your agents do with the status is your decision.
Outcomes and lockout
Every verifier run is recorded as an attempt with one of these outcomes, plus yourreason (first 200 characters). Attempts never store identifiers.
Only
rejected outcomes from the tool trigger count toward lockout, per conversation and per verifier: those are guesses a person typed. A page’s signed payload is not guessable, and a theme with a skewed clock would otherwise lock the verifier on every page load, so session and start failures are logged but never counted.
A tool check takes its place in the count before it runs and holds it until it finishes, so guesses the model makes in parallel cannot get past the limit: once rejections and running checks fill it, a further call is busy until a check finishes. A call cut off mid-check (a hang-up) is recorded as error with the reason cancelled and gives its place back; a check nothing finishes (a worker that stopped) stops holding its place after two minutes.
Example: Shopify customer login
A shopper signed in to your Shopify store should get order help without proving who they are again. The theme signs the customer’s id, email and a timestamp on Shopify’s server, passes the payload toanyreach.identify(), and a verifier workflow checks the signature.
1
Create a shared secret
Generate a random secret, for example with
openssl rand -hex 32. It lives in two places:- Anyreach: a static credential named
shopify_identitywith one key,secret. - Shopify: a shop metafield
anyreach.identity_secretof typesingle_line_text_field, set once with the Admin API’smetafieldsSetmutation (the owner is your shop’s id, from{ shop { id } }). Do not give it Storefront API access: Liquid reads it on Shopify’s servers either way.
2
Sign the payload in the theme
Create Liquid renders on Shopify’s server, so the secret never reaches the browser; only the signature does.
snippets/anyreach-identify.liquid and render it in layout/theme.liquid next to the widget’s embed script with {% render 'anyreach-identify' %}:ts is the page’s render time in Unix seconds. On a signed-out page the snippet calls identify(null), which ends the identity session (see Signing out).3
Build the verifier workflow
A saved workflow with a Pre Call input step (here with the id The freshness check is written as a positive comparison inside the verified branch, so a missing or non-numeric
trigger), a Condition step and one Output step per branch. The id passed to identify() arrives as user_context.external_user_id, the traits beside it.ts falls through to a rejection. To add attributes such as a loyalty tier, put an HTTP step to Shopify’s Admin API (with its own credential) before the verified Output step.4
Attach it and gate the account tools
Add the workflow to
identity_verifiers with the condition user_context.signature and verifies_identity: { "subject_type": "shopify_customer" }, as in Automatic verifiers. Then set requires_verified_identity on every tool that reads or changes account data, as in Gating tools. A customer who is not signed in can still verify with an order + ZIP tool that produces the same subject_type.ts) verifies them again.
To rotate the secret, add the new one as a second credential and accept either in the verifier (as shown here), update the metafield, and remove the old credential once 12 hours have passed.
Test without a theme deploy
Generate a signed payload the way the theme does, with the secret in an environment variable so it stays out of your shell history. Each script prints two things: anidentify() call to paste into the browser console on a page that carries the widget, and a user_context for a test run of the verifier workflow.
POST /workflow/workflow-executions with this as input_data), with the printed object as user_context, both at the top level and under custom_metadata:
identity_verified in the console (window.anyreach.on('identity_verified', console.log)), paste the printed identify() call and open the widget: the header should read “Signed in as priya@example.com”.
In the web widget
The page pushes; Anyreach never calls your store to check a login. Your verifier may call your store’s API to look the customer up, which is a different thing.- On by itself. There is no widget setting: the widget config reports
identity_enabled: truewhenever the agent has identity verifiers, and only then does the widget ask Anyreach to verify anyone. Without verifiers it never does, thoughidentify(null)still drops any token it holds. - Lazily, not per page. The loader passes every
identify()into the widget, and the widget keeps the latest payload. It asks Anyreach to verify only when it needs to: when the chat is opened or a conversation is created without a valid identity, when the identified user changes, and shortly before the identity expires. A new page for the same shopper (a freshts) is not checked again until then; that renewal sends the newest payload. A shopper who never opens the chat costs nothing. A payload with no proof field (signature,ts,token,jwt,hmac), such as the user id the loader remembers on pages that don’t callidentify(), is sent only to renew an identity the widget already holds for that user. While that identity is valid, nothing waits on its renewal. - A failure is not a sign-out. A verifier that times out or errors never signs the shopper out: the widget keeps its identity and tries again shortly (after 30 seconds, then longer). Only a rejected proof counts (so does a request Anyreach refuses outright, such as an oversized payload), and even then a shopper already signed in stays signed in until their identity expires. If the sign-in succeeds but the open chat could not be updated, the shopper is signed in and the widget retries the chat on the same schedule.
- An identity token, kept in the widget. A successful check returns a token that lasts an hour at most (never past the entries’ own expiry). It lives in the widget iframe’s session storage, on Anyreach’s origin, so your page cannot read it and it is gone when the tab closes.
- Carried into conversations. A new conversation starts with the verified entries (
trigger: "session") already in place, so an identity-aware greeting can use them. A chat that is open when the visitor signs in is covered in Signing in mid-chat. - Shown in the header. The widget header reads “Signed in as priya@example.com” (the verified email, or phone), with Not you? Start a new chat, which starts a new, unverified conversation in that tab, without that visitor’s traits, until the page identifies someone again. The widget cannot sign anyone out of your store.
- Reported to your page as host-page events that carry the subject types and methods but never identifiers:
identity_verifiedwhen the widget gets a token for a visitor who was unverified in that tab, or for a different user, andidentity_clearedwhenever it drops a token it held, including after Not you?. Refreshing the same visitor’s token fires neither.
Signing in mid-chat
When a visitor signs in with a guest chat open (live, or still connecting), that chat continues signed in, on this device:- Account tools work from the next message, and the header shows who is signed in.
- The agent is told on its next turn, in a note built from the verified record and never from anything the browser sent: “The customer has signed in. Verified: shopify_customer via shopify_login.” So it stops asking them to sign in or verify, even when a verifier tool had locked after earlier wrong guesses. The note is the platform’s, not a customer message, so a customer who types the same words has only typed them: a text-only widget adds it to the agent’s instructions and keeps it in the conversation’s transcript, at the point the agent was told; any other widget, voice or chat, adds it as a system message the agent keeps for the rest of the session, tool calls included. A renewal of the sign-in before their next message does not lose it.
- The chat is not added to the customer’s history on their other devices: what was typed before the sign-in was typed as a guest, perhaps by someone else on a shared computer. Otherwise it is a signed-in chat like any other: from the sign-in on, it opens only with the customer’s identity, so another tab still showing it as a guest can no longer reopen it (on a text-only widget, that tab leaves it at once and starts a new chat). When they sign out, the widget leaves it and forgets it on this device: it cannot be reopened, not even by them.
- A chat already verified in the conversation as someone else (an order + ZIP proof naming another customer, or an identity of another type), even if that proof has since expired or been replaced, is left as it is, and the widget starts a new chat for the signed-in customer.
- Only the chat open at that moment continues. An ended chat, a chat reopened from the history list, and a guest chat reopened while someone was already signed in stay guest chats; a new chat has the account’s tools.
On a widget that is not text-only the agent hears of the sign-in when the customer next speaks or types, if the conversation can be read within half a second; otherwise at their following message, so the check never delays a reply. Gemini models called through the
google provider take system text only at the start of a conversation, so LiveKit sends them the note the way it sends every mid-conversation instruction, as a user turn wrapped in <instructions> tags, and a Gemini agent with a prompt cache gets it as a plain user turn. An agent on a realtime (speech-to-speech) model is not told, and neither is a session resumed later.Signing out and shared devices
Callidentify(null) on signed-out pages. The widget drops its identity token in every tab, the open conversation (or one still connecting) loses the entries the page sign-in gave it, and conversations bound to that customer leave the history. Entries the customer proved in the conversation itself (a verifier tool) stay: they proved it there. So do the entries of a conversation that has already ended, which cannot be opened without the customer’s identity anyway. This works when the logout is a navigation too: the tab keeps the signed-in chat it had open with its identity, so identify(null) on the next page signs it out. A tab that holds no identity, no chat of theirs and no check in progress ignores another tab’s sign-out.
Nothing of theirs stays on screen either. The widget leaves every chat that belonged to that customer, live or ended, forgets it on this device, and clears the history list and any transcript that was open. A chat still being created when the sign-out lands is signed out and started again. The same happens in that tab when their identity expires, when the page identifies someone else, and after Not you?, except that their chats stay on this device for another tab still signed in as them (without their identity the server refuses them). Guest chats are left as they are; one that a sign-in in another tab takes over can no longer be reopened here, and a text-only widget showing it starts a new chat.
History that follows the customer
A conversation in which someone was verified is bound to them, and the binding outlivesexpires_at.
- When the widget holds an identity, the history lists that customer’s bound conversations on this widget from any device, up to the 50 most recent, alongside the unverified conversations stored on this device. A chat continued at sign-in is listed only on the device it was continued on, until they sign out there.
- A conversation verified through the page sign-in can only be listed, resumed or read with that customer’s identity. Signed out, or signed in as someone else, it is absent from the history and the server refuses it. It is not deleted.
- A conversation verified only by a verifier tool keeps working on its own device as before. It does not follow the customer to other devices.
- A sign-in never takes a conversation that belongs to someone else. If someone else signs in while a conversation another customer verified through the page sign-in is open, the new customer is never written to it, and when they are the same kind of account (another
shopify_customer) that conversation is signed out as well. - Reopening one of their conversations from the history, signed in, gives it back the page sign-in’s entries if it lost them (a sign-out, or their expiry). So does the next message, or the chat’s stream reconnecting, from a tab still signed in as them, when the chat holds no unexpired entry of that type: a sign-out on another device does not leave the chat open here unverified. An ended chat gets nothing back this way.
- While someone is signed in, the history labels any conversation not tied to their account as a Guest chat (“Anyone using this browser can see these”), listed below Your chats. Guest chats are never attached to the account: only the chat open at sign-in continues, and only on that device, so a stranger’s chat on a shared browser cannot follow the next person to their other devices. A visitor who never signs in sees one unlabelled list, as before.
Debugging “why wasn’t I verified?”
The conversation detail view has a Verification panel: each entry (subject type, method, trigger, verified and expiry times, identifiers), the people the conversation is bound to, and every attempt with its trigger, verifier, outcome and reason. Outcomes are named in words (no_evidence reads “Nothing to check”), and so are the reasons the platform records itself (sender_not_owner reads “Sender not the thread owner”, with the code on hover); your verifier’s reason is shown as it returned it. Widget-session attempts are included for every session that reached this conversation: the one it was started with, and each sign-in or refresh made while it was open, failed ones and ones signed out since included. A check that failed before the chat started is listed on the next chat the widget opens on that page within 30 minutes, unless the visitor signed in, signed out or changed in between. Any other failed check never reached a conversation, so its attempts are not listed; check the verifier with a test run or the signed-payload generator instead. The same data is available from GET /core/conversations/{conversation_id}/identity.
Subjects and identifiers follow the same rule as the conversation’s metadata: they need the conversations:read_sensitive scope (see Permissions and data access). Without it the panel shows “Hidden — requires sensitive read access” in their place, and the API returns subject, identifiers and attributes as null with "redacted": true. Methods, triggers, outcomes, reasons and times are always shown.
Common causes:
Security notes
- Sign on the server only. A secret in browser JavaScript ships to every visitor. Liquid runs on Shopify’s server; for a single-page app, have your backend sign the payload and hand it to the page. A session token your verifier checks against your site’s API works as well as a signature.
- Name the user.
identify()must say who the proof is for: an id, an email or a phone, or a JWT (injwtortoken) whosesubnames them. Pass the user’s id alongside an opaque session token:identify('7481029', { token }). A payload that names no one is not verified, and the widget logs a warning in the browser console. - A copied payload is replayable inside its window. Keep the window as short as your pages allow, and sign the customer’s id, not just an email.
- Proof fields stay out of the record and your analytics.
signature,ts,token,jwtandhmacare removed fromuser_contextbefore it is stored on a conversation, and from the traits of theidentifyhost event, so the signed payload does not end up in conversation metadata, exports or your data layer. Use those names for proof fields. The verifier still receives them, but the runs Anyreach starts for it store them as«redacted», in the input and wherever a step copied them unchanged. - Templates and workflows read
conversation_metadata.*. Gated workflows take the subject fromconversation_metadata.gated_identity, never from tool arguments orcustom_metadata. - Verified data is still user text. A verified first name is what the customer typed into Shopify. Treat identifiers and attributes as data in prompts, not instructions, and store flags or references in
attributesrather than raw sensitive values. - Weak channels, narrow methods. Caller ID can be spoofed. On email, only the thread’s owner can prove anything, identity taken from the sender address needs a DMARC
pass, an in-chat proof carries over to later mail only with a DMARCpass, and replies on a verified thread go only to its owner (On email). Give sender-based verifiers their ownmethodand list it inmethodsonly on tools where that is enough. - The lockout is per conversation. It stops guessing inside one conversation, not across many.
Related
Signing requests
$hmacVerify, $hmac and the rest of the crypto functions.Host-page events
identify(), and the identity_verified and identity_cleared events.Tools
How the model decides to call a tool and what it passes.
Credentials
Store the signing secret a verifier reads.

