> ## Documentation Index
> Fetch the complete documentation index at: https://docs.anyreach.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Verified identity

> Let your own workflows prove who a customer is, gate account tools on it, and tie chat history to the person instead of the device.

Every conversation arrives with a claim about who the person is: the traits your page passes to `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.

```text theme={null}
claim (identify() payload, caller ID, email sender, what the customer typed)
   │
   ▼
verifier workflow ── your check: a signature, an API lookup, order + ZIP
   │
   ├── { verified_identity: {...} }   → the platform stamps and stores an entry
   └── { verified: false, reason }    → the attempt is logged, nothing is stored
   │
   ▼
gated tool (requires_verified_identity): runs with the entry, or tells the model to verify first
```

## The record

Each conversation holds at most one entry per `subject_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](#verifier-tools-mid-conversation)). 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.

```jsonc theme={null}
{
  "subject": "7481029",                // the id your tools look things up by, always a string
  "subject_type": "shopify_customer",
  "method": "shopify_login",           // your own name for how it was proved
  "identifiers": { "email": "priya@example.com", "loyalty_member_id": "A-2231" },
  "attributes": { "loyalty_tier": "gold" },
  "level": null,                       // reserved; always null for now
  "verified_by": {                     // stamped by the platform
    "trigger": "session",              // session, start or tool
    "verifier_key": "workflow:4b1c…",
    "workflow_id": "4b1c…",
    "workflow_version": 3,
    "execution_id": "…"
  },
  "verified_at": "2026-09-25T10:12:03Z",   // stamped by the platform
  "expires_at": "2026-09-26T10:12:03Z"     // verified_at + the verifier's ttl
}
```

* **`identifiers`** answers "who is this in another system". `email` and `phone` are reserved: `email` is lower-cased and dropped unless it contains `@`, `phone` keeps only digits and `+` and is dropped under seven digits. A verified email or phone becomes the first identity used for [human handoff](#human-handoff) (Gladly, for example), ahead of anything the customer typed. Other keys are yours.
* **`attributes`** is opaque to the platform: validated against your schema, never interpreted.
* **Expiry is not deletion.** Past `expires_at` the entry stays on the conversation (for history and analytics), but tools stop treating it as verified.

### Where to read it

| Path | What it holds |
| - | - |
| `conversation_metadata.verified_identity` | The most recently verified entry that has not expired, or `null`. |
| `conversation_metadata.verified_identities` | Every unexpired entry, keyed by `subject_type`. |
| `conversation_metadata.gated_identity` | In a gated tool's workflows only: the entry that satisfied the gate. |

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](#signing-in-mid-chat)); nothing else is injected into the prompt automatically, so reference the record where you want it:

```jinja theme={null}
{% if conversation_metadata.verified_identity %}
The customer is signed in as {{ conversation_metadata.verified_identity.identifiers.email }}.
Do not ask them to verify again.
{% endif %}
```

<Warning>
  Always read the record through `conversation_metadata`. `custom_metadata` is written by the browser: the platform strips the names `verified_identity`, `verified_identities` and `gated_identity` from it, but any other key there (a `verified_customer_id`, say) is whatever the visitor sent.
</Warning>

## Writing a verifier workflow

A verifier is an ordinary [workflow](/workflows/overview) 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 up `custom_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.

| Input field | Set by | Trust |
| - | - | - |
| `conversation_metadata.channel`, `direction`, `widget_id` | the platform | yes |
| `conversation_metadata.verified_identities`, `verified_identity`, `gated_identity` | the platform | yes |
| `conversation_metadata.user_id` on telephone or SMS | the carrier | the channel's claim: caller ID can be spoofed |
| `conversation_metadata.active_sender` on email | the sender, checked by the receiving mail server | only when `conversation_metadata.email_auth.dmarc` is `pass`, and only from the thread's owner. Start verifiers on email only run then; a verifier tool also runs on the owner's mail that did not pass |
| `conversation_metadata.user_id` on the widget | the browser | no: a random id the browser picked |
| `user_context` and every other `custom_metadata` key | the browser | no: a claim to check |
| `arguments.*` on a verifier tool | the model, from what the customer typed | no: a claim to check |

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](#on-email).

### Output

The workflow's Output step returns one of three shapes:

| Output | Meaning |
| - | - |
| `verified_identity`: an object with `subject`, `method`, and optionally `subject_type`, `identifiers`, `attributes` | Verified. The platform adds `verified_by`, `verified_at` and `expires_at` and stores the entry. |
| `verified: false` with `reason: "no_evidence"` | Nothing to check, e.g. a visitor who never signed in. Not a failed attempt. |
| `verified: false` with any other `reason` | Rejected. `reason` is your own vocabulary (`bad_signature`, `zip_mismatch`) and is stored with the attempt. |

`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_id` and `workflow_version`) or an inline definition; both read workflow [credentials](/workflows/credentials/overview), 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_context` and an empty `arguments` (`{}`), so they can start from a **Pre Call** or an **Ongoing Conversation** input step. A verifier attached as a tool receives the tool's real `arguments` and 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`](/workflows/expressions/signing-requests#verifying-a-signature), 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](#test-without-a-theme-deploy).

### Verifier settings

Marking a workflow action with `verifies_identity` is what makes it a verifier. Without it, a workflow's output can never write the record.

| Field | Type | Default | Description |
| - | - | - | - |
| `subject_type` | string | required | The kind of identity this verifier proves. 1 to 64 characters. |
| `output_schema` | object | `null` | JSON Schema checked against `{ "identifiers": …, "attributes": … }`. Declare your custom identifier keys and attributes here. |
| `ttl` | integer | `86400` | Seconds the entry stays valid. 60 to 604800 (seven days). |
| `lockout` | object or `null` | `{ "max_failures": 3 }` | Rejected tool attempts allowed per conversation before the verifier locks (1 to 100). A check still running holds one of them, so parallel attempts cannot exceed it. `null` disables it. |

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

| Trigger | Channels | When | Time limit |
| - | - | - | - |
| `session` | web widget (chat and voice) | When the widget needs an identity for a visitor your page identified. See [In the web widget](#in-the-web-widget). | 10 s |
| `start`, `before_start` timing | telephone, email, SMS | Before the pre-conversation workflow and the greeting | 3 s |
| `start`, `background` timing | telephone, email, SMS | Once the conversation has started, without delaying the greeting | 25 s |
| `tool` | every channel | When the model calls the verifier as a tool | 25 s |

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](#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](/agents/abilities-and-actions#workflow) (`workflow_id`, `workflow_version`, `initial_step`), with these fields doing the work:

| Field | Type | Default | Description |
| - | - | - | - |
| `verifies_identity` | object | required | See [Verifier settings](#verifier-settings). |
| `timing` | `"background"` or `"before_start"` | `"background"` | Conversation start only; the widget session runs every entry whatever its timing. `before_start` is for openings that depend on identity ("Hi Priya, calling about order #1042?"); the pre-conversation workflow then also sees the result. Recommended on phone and email. |
| `condition` | string | `null` | Run this verifier only when the expression is truthy. |

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.*`:

| Condition | Runs |
| - | - |
| `user_context.signature` | Only when the page passed a signed payload. |
| `conversation_metadata.channel == 'telephone'` | Only on phone calls. |
| `conversation_metadata.channel in ['email', 'text']` | Only on email and SMS. |
| `conversation_metadata.widget_id == '<widget id>'` | Only for one site's widget. |

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.

```json theme={null}
{
  "identity_verifiers": [
    {
      "type": "workflow",
      "workflow_id": "<shopify login verifier>",
      "workflow_version": 1,
      "initial_step": "trigger",
      "condition": "user_context.signature",
      "verifies_identity": { "subject_type": "shopify_customer" }
    },
    {
      "type": "workflow",
      "workflow_id": "<caller lookup verifier>",
      "workflow_version": 2,
      "initial_step": "trigger",
      "timing": "before_start",
      "condition": "conversation_metadata.channel == 'telephone'",
      "verifies_identity": { "subject_type": "shopify_customer", "lockout": null }
    }
  ]
}
```

<Note>
  A verifier is not the [pre-conversation workflow](/agents/advanced-configuration#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.
</Note>

### Verifier tools (mid-conversation)

Any verifier can also be attached as an ordinary [tool](/agents/tools): add the workflow to a tool's actions and set `verifies_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](/workflows/credentials/overview). 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"`.

```json theme={null}
{
  "name": "verify_by_order_zip",
  "description": "Verify the customer from an order number and the order's delivery ZIP code. Use it when another tool says the customer needs to verify.",
  "properties": {
    "order_number": { "type": "string", "description": "The order number, e.g. #1042" },
    "zip": { "type": "string", "description": "The delivery ZIP or postcode" }
  },
  "required": ["order_number", "zip"],
  "actions": [
    {
      "type": "workflow",
      "workflow_id": "<order + zip verifier>",
      "workflow_version": 2,
      "initial_step": "trigger",
      "verifies_identity": { "subject_type": "shopify_customer", "ttl": 3600 }
    }
  ]
}
```

The model never sees the workflow's own output. It gets `{"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

Set `requires_verified_identity` on a [tool](/agents/tools) 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.

| Field | Type | Default | Description |
| - | - | - | - |
| `subject_type` | string | required | The kind of identity the tool needs. |
| `methods` | string\[] | `null` (any) | Accept only entries verified by these methods. |
| `unverified_result` | string | `null` | Jinja template returned to the model instead of the default refusal. |

* **Found:** the actions run, and their workflows receive the entry as `conversation_metadata.gated_identity`. Take the customer id from `gated_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

```json theme={null}
{ "needs_verification": true, "subject_type": "shopify_customer", "available_verifiers": ["verify_by_order_zip"] }
```

`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.

```json theme={null}
{
  "name": "get_order_status",
  "description": "Look up the customer's recent orders and their shipping status.",
  "requires_verified_identity": {
    "subject_type": "shopify_customer",
    "methods": ["shopify_login", "order_zip"],
    "unverified_result": "{% if available_verifiers %}Ask for the order number and delivery ZIP code, then call {{ available_verifiers | join(' or ') }}.{% else %}Offer to connect the customer with a person.{% endif %}"
  },
  "actions": [
    { "type": "workflow", "workflow_id": "<order lookup>", "workflow_version": 4, "initial_step": "trigger" }
  ]
}
```

The gate belongs to the tool, not to one of its actions, so it stops the whole action list and its refusal is the tool's result. A refusal is not an error: `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 DMARC `pass` 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:

| The batch | Email identity this turn |
| - | - |
| From the owner alone, and every message passed DMARC | Start verifiers and verifier tools run; entries proved from the owner's address count. |
| From the owner alone, but a message did not pass DMARC or has no verdict | Verifier tools run; only entries proved in this batch count; start verifiers do not run. |
| From one address that is not the owner's | None. Nothing runs and no entry counts; a verifier tool returns `sender_not_owner`. |
| More than one From address, or a message whose Reply-To differs from its From | None. Nothing runs and no entry counts; a verifier tool returns `ambiguous_sender`. |

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_evidence` with the reason `sender_unauthenticated`, `sender_not_owner` or `ambiguous_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.

So a CC'd or forwarded participant can neither inherit the owner's identity nor prove one of their own and take the thread's replies, and forged mail from the owner's address can neither use their identity nor redirect the answer.

## 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:

| The customer | The line under the title |
| - | - |
| Verified | `Identity: verified by Anyreach. Method: shopify_login`, with your verifier's `method` |
| Not verified | `Identity: not verified by Anyreach.` |

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 your `reason` (first 200 characters). Attempts never store identifiers.

| Outcome | Meaning |
| - | - |
| `verified` | The verifier returned a valid `verified_identity`. |
| `rejected` | The verifier returned `verified: false` with a reason. |
| `no_evidence` | The verifier returned `reason: "no_evidence"`, or the platform did not run it: on email with reason `sender_unauthenticated`, `sender_not_owner` or `ambiguous_sender` (see [On email](#on-email)), and as a tool in a chat holding a page sign-in of its type with reason `signed_in`. |
| `error` | The workflow failed or could not be reached. |
| `invalid_output` | The output matched neither shape or failed validation. |
| `locked` | A verifier tool refused to run: too many rejections in this conversation. |
| `busy` | A verifier tool refused to run: checks still running held every lockout slot. |
| `running` | A verifier tool's check that has not finished. It changes to its outcome when the check ends. |
| `timeout` | The verifier did not finish in its time limit. |

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 to `anyreach.identify()`, and a verifier workflow checks the signature.

<Steps>
  <Step title="Create a shared secret">
    Generate a random secret, for example with `openssl rand -hex 32`. It lives in two places:

    * **Anyreach:** a static [credential](/workflows/credentials/overview) named `shopify_identity` with one key, `secret`.
    * **Shopify:** a shop metafield `anyreach.identity_secret` of type `single_line_text_field`, set once with the Admin API's `metafieldsSet` mutation (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.
  </Step>

  <Step title="Sign the payload in the theme">
    Create `snippets/anyreach-identify.liquid` and render it in `layout/theme.liquid` next to the widget's embed script with `{% render 'anyreach-identify' %}`:

    ```liquid theme={null}
    <script>
      window.anyreach = window.anyreach || [];
    {%- if customer %}
      {%- assign anyreach_ts = 'now' | date: '%s' -%}
      {%- capture anyreach_message -%}{{ customer.id }}:{{ customer.email }}:{{ anyreach_ts }}{%- endcapture -%}
      {%- assign anyreach_signature = anyreach_message | hmac_sha256: shop.metafields.anyreach.identity_secret.value %}
      window.anyreach.push(['identify', '{{ customer.id }}', {
        email: {{ customer.email | default: '' | json }},
        first_name: {{ customer.first_name | json }},
        ts: '{{ anyreach_ts }}',
        signature: '{{ anyreach_signature }}'
      }]);
    {%- else %}
      window.anyreach.push(['identify', null]);
    {%- endif %}
    </script>
    ```

    Liquid renders on Shopify's server, so the secret never reaches the browser; only the signature does. `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](#signing-out-and-shared-devices)).
  </Step>

  <Step title="Build the verifier workflow">
    A saved workflow with a **Pre Call** input step (here with the id `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.

    ```jsonc theme={null}
    // Condition step, branches in this order
    // 1. no_evidence
    {{ $not($exists(trigger.user_context.signature)) }}
    // 2. verified: a valid signature, at most 12 hours old and at most 5 minutes in the future
    {{ (
      $uc := trigger.user_context;
      $ts := $number($uc.ts);
      $now := $millis() / 1000;
      $ts > $now - 43200 and $ts < $now + 300 and
      $hmacVerify($credential('shopify_identity').secret,
                  $uc.external_user_id & ':' & $uc.email & ':' & $uc.ts,
                  $uc.signature)
    ) }}
    // 3. stale: the signature is valid, so the timestamp is what failed
    {{ (
      $uc := trigger.user_context;
      $hmacVerify($credential('shopify_identity').secret,
                  $uc.external_user_id & ':' & $uc.email & ':' & $uc.ts,
                  $uc.signature)
    ) }}
    // default: bad_signature
    ```

    ```jsonc theme={null}
    // Output step on "verified"
    {
      "verified_identity": {
        "subject": "{{ trigger.user_context.external_user_id }}",
        "subject_type": "shopify_customer",
        "method": "shopify_login",
        "identifiers": { "email": "{{ trigger.user_context.email }}" }
      }
    }
    // Output steps on the other branches
    { "verified": false, "reason": "no_evidence" }
    { "verified": false, "reason": "stale_timestamp" }
    { "verified": false, "reason": "bad_signature" }
    ```

    The freshness check is written as a positive comparison inside the verified branch, so a missing or non-numeric `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.
  </Step>

  <Step title="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](#automatic-verifiers). Then set `requires_verified_identity` on every tool that reads or changes account data, as in [Gating tools](#gating-tools). A customer who is not signed in can still verify with an order + ZIP tool that produces the same `subject_type`.
  </Step>
</Steps>

The 12-hour window bounds replay: a payload copied out of a page stops working 12 hours after that page rendered. It also bounds renewal. About once an hour the widget re-verifies with the newest payload a page gave it; once that payload is too old, the renewal is rejected, the shopper's identity in the widget runs out a few minutes later, and the next page the shopper opens (a fresh `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](/workflows/expressions/signing-requests#verifying-a-signature)), 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: an `identify()` 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.

<CodeGroup>
  ```js sign-identify.mjs theme={null}
  // ANYREACH_IDENTITY_SECRET=... node sign-identify.mjs 7481029 priya@example.com
  import { createHmac } from 'node:crypto';

  const [customerId, email] = process.argv.slice(2);
  const ts = String(Math.floor(Date.now() / 1000));
  const signature = createHmac('sha256', process.env.ANYREACH_IDENTITY_SECRET)
    .update(`${customerId}:${email}:${ts}`)
    .digest('hex');
  const traits = { email, ts, signature };

  console.log(`window.anyreach.identify(${JSON.stringify(customerId)}, ${JSON.stringify(traits)});`);
  console.log(JSON.stringify({ external_user_id: customerId, ...traits }, null, 2));
  ```

  ```python sign_identify.py theme={null}
  # ANYREACH_IDENTITY_SECRET=... python3 sign_identify.py 7481029 priya@example.com
  import hashlib
  import hmac
  import json
  import os
  import sys
  import time

  customer_id, email = sys.argv[1], sys.argv[2]
  ts = str(int(time.time()))
  signature = hmac.new(
      os.environ["ANYREACH_IDENTITY_SECRET"].encode(),
      f"{customer_id}:{email}:{ts}".encode(),
      hashlib.sha256,
  ).hexdigest()
  traits = {"email": email, "ts": ts, "signature": signature}

  print(f"window.anyreach.identify({json.dumps(customer_id)}, {json.dumps(traits)});")
  print(json.dumps({"external_user_id": customer_id, **traits}, indent=2))
  ```
</CodeGroup>

Then [run the verifier](/workflows/testing-workflows) (or `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`:

```json theme={null}
{
  "conversation_metadata": {
    "id": "",
    "organization_id": "<your org id>",
    "direction": "inbound",
    "user_id": "",
    "agent_number": "",
    "channel": "webchat",
    "status": "triggered",
    "created_at": "2026-09-25T10:12:03Z",
    "widget_id": "<widget id>"
  },
  "custom_metadata": { "user_context": { "external_user_id": "7481029", "email": "priya@example.com", "ts": "…", "signature": "…" } },
  "user_context": { "external_user_id": "7481029", "email": "priya@example.com", "ts": "…", "signature": "…" }
}
```

A test run from the workflow editor is stored as sent, signature included, so sign for a test customer rather than a real one.

On the site, subscribe to `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](mailto: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: true` whenever the agent has identity verifiers, and only then does the widget ask Anyreach to verify anyone. Without verifiers it never does, though `identify(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 fresh `ts`) 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 call `identify()`, 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](#signing-in-mid-chat).
* **Shown in the header.** The widget header reads "Signed in as [priya@example.com](mailto: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](/web-widgets/host-page-events#identity) that carry the subject types and methods but never identifiers: `identity_verified` when the widget gets a token for a visitor who was unverified in that tab, or for a different user, and `identity_cleared` whenever 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.

<Note>
  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.
</Note>

**A slow first check.** When a page identifies the visitor, the widget waits up to 3 seconds for the check before it opens a chat. If the check is still running then, the chat opens as a guest chat holding a 60-second bind ticket. The check went out before the chat existed, so when it succeeds the widget runs it once more with the chat and its ticket; within that minute the chat is bound as if it had started signed in, and it does follow the customer to their other devices. A check that timed out is retried with both, and after the minute the chat continues on this device only. The ticket is for that check's visitor only: the check being refused, a sign-out, or the page naming someone else discards it, and an older guest chat never has a valid one.

### Signing out and shared devices

Call `identify(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 outlives `expires_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](/conversations/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](/conversations/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:

| Symptom | Likely cause |
| - | - |
| No attempt at all | The verifier's `condition` was false (no `signature` in `user_context`), the widget has not needed an identity yet (open it), or the session failed before this chat started (see above). |
| `no_evidence` | The verifier had nothing to check, e.g. it has no `condition` and ran for a visitor who never signed in. |
| `no_evidence`, reason `sender_unauthenticated` | An email start verifier did not run: the mail did not pass DMARC. |
| `no_evidence`, reason `sender_not_owner` | The email came from someone other than the thread's owner (the sender of its first message, or whom your agent first emailed). |
| `no_evidence`, reason `ambiguous_sender` | The email turn had messages from more than one address, or a Reply-To that differs from its From, or no message at all yet. |
| `no_evidence`, reason `signed_in` | The model called a verifier tool in a chat whose customer is signed in through the page. Nothing ran, and the chat stays theirs. |
| `bad_signature` (your reason) | The signed string differs from the one the verifier rebuilds (field order, an `email` that is `null` on one side and empty on the other), or the secrets differ. |
| `stale_timestamp` (your reason) | The page is older than your window, or the store's clock is off. |
| `error` | The workflow failed: often a missing or misnamed credential. |
| `invalid_output` | The Output step returned neither shape, `subject_type` did not match, or `output_schema` rejected it or could not be applied (not a valid schema, or a `$ref` that does not resolve). The reason says which. |
| `busy` | The model called the verifier while its earlier checks, often guesses made together, were still running. Nothing ran, and it does not count toward the lockout. |
| `error`, reason `cancelled` | The call ended mid-check, a hang-up for example. The check gave its lockout slot back. |
| `running` that never changes | A worker stopped mid-check. Two minutes after it started, it no longer holds a lockout slot. |

## 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 (in `jwt` or `token`) whose `sub` names 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`, `jwt` and `hmac` are removed from `user_context` before it is stored on a conversation, and from the traits of the `identify` host 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 from `conversation_metadata.gated_identity`, never from tool arguments or `custom_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 `attributes` rather 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 DMARC `pass`, and replies on a verified thread go only to its owner ([On email](#on-email)). Give sender-based verifiers their own `method` and list it in `methods` only on tools where that is enough.
* **The lockout is per conversation.** It stops guessing inside one conversation, not across many.

## Related

<CardGroup cols={2}>
  <Card title="Signing requests" icon="signature" href="/workflows/expressions/signing-requests#verifying-a-signature">
    `$hmacVerify`, `$hmac` and the rest of the crypto functions.
  </Card>

  <Card title="Host-page events" icon="bolt" href="/web-widgets/host-page-events">
    `identify()`, and the `identity_verified` and `identity_cleared` events.
  </Card>

  <Card title="Tools" icon="wrench" href="/agents/tools">
    How the model decides to call a tool and what it passes.
  </Card>

  <Card title="Credentials" icon="key" href="/workflows/credentials/overview">
    Store the signing secret a verifier reads.
  </Card>
</CardGroup>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.