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

# Staying in sync

> Know whether an account is secured right now: live in the page, pushed to your server, and confirmed on every fresh pass.

A person can unlink an account in the BotShield app at any time, and your organization can revoke one in the Console. This page shows the three places your platform learns an account's current state, and how to use them together.

| Where | How | What you get |
| - | - | - |
| [In the page](#in-the-page-live) | The widget holds a live connection | `botshield:presence` on every change, with no tap |
| [On your server, pushed](#on-your-server-pushed) | Webhooks | `account.secured` and `account.unlinked`, with the account's handle |
| [On a fresh pass](#on-a-fresh-pass) | `POST /sdk/verify-token` | `trusted: true` or `false` for this account, signed |

None of these tells you who the person is.

## In the page, live

When the widget offers Link with BotShield ID, it also keeps a live connection to BotShield that answers one question: is this account secured right now? It needs no tap.

```html theme={null}
<botshield-verify
  site-key="pk_live_..."
  notarize
  platform-user-ref="usr_8f31c2d9"
></botshield-verify>
```

The widget opens the connection only when all of these hold:

* The offer is on in the Console, on the site key's Link with BotShield ID surface or on the gate named in `scope`.
* `platform-user-ref` is set.
* `site-key` is a `pk_test_...` or `pk_live_...` key, and the page origin is in its allowed origins.

### `botshield:presence`

The widget fires `botshield:presence` with its first answer, and again each time the answer changes.

```json theme={null}
{ "state": "none", "fresh_until": null }
```

| `state` | Meaning |
| - | - |
| `none` | The account is not secured, or no longer secured: the person unlinked or your organization revoked. |
| `fresh` | The account is secured. |
| `stale` | The account is secured. |

`fresh` and `stale` both mean secured. `fresh_until` is a time BotShield works out from the person's latest passkey use. It is `null` when `state` is `none`. BotShield does not send a change from `fresh` to `stale` that comes only from the clock passing `fresh_until`. If you need that, compare `fresh_until` with your own clock.

The detail never carries a handle, a BotShield ID, a gate name or a request ID.

The widget acts on the answer itself: on `none` it puts the **Link with BotShield ID** chip back, and on `fresh` or `stale` it shows **Account secured**. Listen for the event when the rest of your page has to change too.

```html theme={null}
<script>
  const widget = document.querySelector('botshield-verify');

  widget.addEventListener('botshield:presence', (e) => {
    const secured = e.detail.state === 'fresh' || e.detail.state === 'stale';

    // Your own page around the chip.
    document.querySelector('#secured-badge').hidden = !secured;
    document.querySelector('#secure-prompt').hidden = secured;

    if (!secured) {
      console.info('This account is not secured with BotShield.');
    }
  });

  widget.addEventListener('botshield:presence-error', (e) => {
    // Fired once. The widget keeps asking every 60 seconds.
    console.info('BotShield presence:', e.detail.reason);
  });
</script>
```

Treat the event as a display signal. Make access decisions on your server, from the token and your webhook records.

### When the connection drops

| Situation | What the widget does |
| - | - |
| The connection drops | It reconnects, waiting a little longer each time, up to 30 seconds. |
| Three connections in a row fail with no answer | It fires `botshield:presence-error` once, with `{ "reason": "stream_unavailable" }`, then asks BotShield every 60 seconds. |
| The tab becomes visible again, or the page is restored from the browser's back-forward cache | It asks BotShield once right away, so a change made while the tab was hidden shows at once. |
| `platform-user-ref`, `site-key` or `scope` changes | It drops the old answer and starts again for the new account. |

The widget never shows an account as secured without an answer from BotShield.

<Note>
  The re-read when the tab becomes visible again needs widget `3.0.12` or later — the current stable version at `/sdk.js`. Pinned `3.0.9` has the live connection and the 60-second fallback only. See [Widget versions](/gate/widget-versions).
</Note>

## On your server, pushed

Two webhook events keep your records in step when the person is not on your site. Both carry the account's `handle`, an opaque value that starts with `OP_`, and never your `platform-user-ref`.

| Event | Sent when | Fields |
| - | - | - |
| `account.secured` | A person secured an account. Once per new link. | `handle`, `secured_at`, `surface_id` |
| `account.unlinked` | A link ended. | `handle`, `by` (`human` or `platform`), `unlinked_at`, `reason` (`human`, `platform` or `credential_changed`), `surface_id` |

`surface_id` names the Link with BotShield ID surface the account was secured on. It is `null` for a link made through a gate. See [Token and webhooks](/secured-accounts/token-and-webhooks#account-secured) for the full payloads.

```typescript theme={null}
// After you verify the Svix signature. See the Webhooks overview.
async function onAccountEvent(event: {
  type: string;
  handle: string;
  by?: "human" | "platform";
  reason?: "human" | "platform" | "credential_changed";
}) {
  switch (event.type) {
    case "account.secured":
      // Store the handle. Match it to your user through the gate.human_verified
      // event for the same request, which carries the same handle.
      await db.botshieldLinks.upsert({ handle: event.handle, secured: true });
      break;
    case "account.unlinked":
      await db.botshieldLinks.update({ handle: event.handle }, { secured: false });
      break;
  }
}
```

Account events have no `environment` field, and a person's handle is the same in Development and Production. Keep Development traffic away from the endpoint your production system reads. Set up delivery, signatures and deduplication in the [Webhooks overview](/webhooks/overview).

## On a fresh pass

Every time the person passes, with the chip or at one of your gates, your page receives a signed token. Your server checks it with `POST /sdk/verify-token`, and the claims say whether the account is secured at that moment. At a gate, send the same `platform-user-ref` with the request, or the claim has no account to look up and reads `false`.

```bash theme={null}
curl -s https://api.botshield.ai/operations/sdk/verify-token \
  -H "Content-Type: application/json" \
  -d '{"token":"eyJhbGciOiJFUzI1NiIsImtpZCI6...","audience":"org_..."}'
```

```json theme={null}
{
  "data": {
    "data": {
      "valid": true,
      "verdict": "pass",
      "replayed": false,
      "claims": {
        "request_id": "req_4b1f0c6e2a9d4e7f8a3b5c6d7e8f9a0b",
        "organization_id": "org_...",
        "trusted": true,
        "trusted_since": "2026-10-20T17:04:05.000Z",
        "last_pass_at": "2026-11-02T09:15:22.000Z"
      }
    }
  }
}
```

Read `data.data.claims.trusted`. It is `true` only when an active link exists for your platform and the `platform-user-ref` that came with the pass. It is `false` for an account that was never secured, or was unlinked. This is the strongest of the three answers: it is signed, and it comes from a live human who just used their passkey. See [Token and webhooks](/secured-accounts/token-and-webhooks#the-token) for every claim and check.

## Ask from your server

Your server can read and manage Secured Accounts with its API key (`bs_dev_…` for development, `bs_production_…` for production; the key decides the environment). SDK 2.2.0 and later: `client.securedAccounts.*`.

| Call | What it does |
| - | - |
| `GET /secured-accounts/status` · `securedAccounts.getStatus` | `{ state, fresh_until }` for your `platform_user_ref`, without a tap. The same answer the widget's live connection gives. |
| `GET /secured-accounts/list` · `securedAccounts.list` | Your registry: `handle`, `status`, `secured_at`, `unlinked_at`, `unlinked_by`, `unlink_reason`, `surface_id`. Filter by `platform_user_ref` or `status`. |
| `POST /secured-accounts/revoke` · `securedAccounts.revoke` | Unlink by `platform_user_ref` or `handle`, with `reason: platform` or `credential_changed`. Sends `account.unlinked` and flips open widgets to `none`. Safe to repeat. |

```ts theme={null}
import { BotShield } from "botshield-sdk";

const botshield = new BotShield({ serverURL: "https://api.botshield.ai/operations" });
const key = { apiKeyAuth: `Bearer ${process.env.BOTSHIELD_API_KEY}` };

// A user changed their password: end the link; they re-link behind your own re-auth.
const revoked = await botshield.securedAccounts.revoke(key, { platformUserRef: user.id, reason: "credential_changed" });
if (revoked.data.error) throw new Error(revoked.data.error.message);

// Reconcile after a missed webhook.
const status = await botshield.securedAccounts.getStatus(key, { platformUserRef: user.id });
```

None of these returns who the person is: no name, email, device, or BotShield user identifier.

## Putting it together

<Steps>
  <Step title="On account.secured">
    Store the handle beside your user, and mark the account secured.
  </Step>

  <Step title="On every pass">
    Verify the token on your server. Update your record from `trusted`.
  </Step>

  <Step title="On account.unlinked">
    Find the user by handle and mark the account not secured.
  </Step>

  <Step title="When a sign-in credential changes">
    Call `securedAccounts.revoke` with `reason: credential_changed`. The person re-links in one tap behind your own re-auth.
  </Step>

  <Step title="If you missed an event">
    Call `securedAccounts.getStatus` or `securedAccounts.list` to reconcile.
  </Step>

  <Step title="In the page">
    Listen for `botshield:presence` and update what the person sees while they are on your site.
  </Step>
</Steps>


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