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

# Inline passkey in your app

> Run the person's BotShield passkey inside your own native app, against one verification request, without switching to the BotShield app. You learn only the outcome.

> [!NOTE]
>
> Beta. The two endpoints on this page are live in Production. Shapes can still change without a major version. iOS is tested end to end. Android is not tested yet. See [Android](#android).

The **inline passkey** lets a person confirm a BotShield request inside your app. The passkey sheet opens over your screen, the person unlocks it the way they unlock their phone, and the request completes. They never leave your app.

Nothing about the person changes hands:

* **You learn:** only the outcome, `verified` or a reason it did not happen.
* **You never hold:** a BotShield session, a BotShield ID, a verification token from this call, or anything about the person.
* **The passkey stays BotShield's.** It belongs to BotShield's relying party, `botshield.ai`. Your app runs the platform's passkey call with the options BotShield gives you and sends the answer straight back.

You read the result the way you already do: [`GET /verification/status`](https://docs.botshield.ai/gate/verify-on-your-server#pattern-b-server-to-server), the signed token, and the `gate.human_verified` webhook. Your server code does not change.

It is an **option**. The hand-off to the BotShield app (a link, a QR code or a push) keeps working, and every failure here falls back to it. See [Open BotShield](https://docs.botshield.ai/native/open-botshield.md).

## When it works

The inline passkey runs only when all of these are true:

| Requirement | Why |
| - | - |
| **A native app you registered** with BotShield, and BotShield approved it. | iOS and Android only let an app use a `botshield.ai` passkey when `botshield.ai` names the app. See [Register your app](https://docs.botshield.ai/native/register-your-app.md). |
| **The request points at one person.** You created it with `partner_user_ref`, and that person has [secured their account](https://docs.botshield.ai/secured-accounts/overview.md) with you in the same environment. | BotShield offers only that person's passkeys. With no linked person, BotShield answers `fallback` with `reason: "not_linked"`. |
| **That person has a BotShield passkey**, and it is on this device or synced to it. | BotShield knows which passkeys the person has, not which device holds them. Your app finds out on the device. |
| **The request is still open.** | A verification request lives 5 minutes. |

A person who has never used BotShield can't confirm inline: they create a BotShield ID and passkey in BotShield first. Creating a passkey always happens in BotShield, never in your app.

> [!WARNING]
>
> **Not on an Age Gate.** The age signal is read by the BotShield app on the person's phone. Use the [Open BotShield](https://docs.botshield.ai/native/open-botshield.md) hand-off for an Age Gate, as the web widget does.

### Web pages

A web page on your own domain can't run a `botshield.ai` passkey. Browsers allow it only on `botshield.ai` origins, and BotShield does not publish a related-origins list for partner domains. On the web, use the widget's [inline passkey (Beta)](https://docs.botshield.ai/gate/web-component.md#inline-passkey-beta): it runs the passkey in a BotShield frame on your page, on desktop. Phones use the hand-off.

A hybrid app (Capacitor, React Native, a WebView) counts as native here. Run the passkey with a native plugin, not with `navigator.credentials` in the WebView.

## The sequence

```mermaid theme={null}
sequenceDiagram
    participant App as Your app
    participant S as Your server
    participant A as BotShield API
    App->>S: The person taps your action
    S->>A: POST /sdk/create-session (API key)
    S->>A: POST /sdk/create-verification-link (scope, partner_user_ref)
    A-->>S: request_id, web_url, expires_at
    S-->>App: request_id, web_url
    App->>A: POST /request/passkey-options { request_id }
    A-->>App: options, challenge_id, url, setup_url
    App->>App: Platform passkey sheet (rpId botshield.ai)
    App->>A: POST /request/passkey-complete { request_id, challenge_id, response }
    A-->>App: { outcome: "verified" }
    A->>S: gate.human_verified webhook
    App->>S: Continue
    S->>A: GET /verification/status (or use the webhook)
    A-->>S: completed, verification_token
```

**1. Create the request on your server**

Create the request exactly as in [Pattern B](https://docs.botshield.ai/gate/verify-on-your-server.md#pattern-b-server-to-server): `POST /sdk/create-session` with your API key, then `POST /sdk/create-verification-link` with the grant token. Send `partner_user_ref`, the same stable ID the person secured their account with.

Return only `request_id` and `web_url` to the app. Your API key never goes into the app.

**2. Ask BotShield for the passkey options**

From the app, call `POST /request/passkey-options`. It is public: the `request_id` is the only credential, and it is unguessable and short-lived.

```bash theme={null}
curl -s -X POST https://api.botshield.ai/operations/request/passkey-options \
  -H "Content-Type: application/json" \
  -d '{"request_id":"req_4b1f0c6e2a9d4e7f8a3b5c6d7e8f9a0b"}'
```

Every answer is HTTP `200`, wrapped as `{ "data": { "data": … } }`. Read `.data.data`. It has one of three shapes.

> [tab] options
>
> The request resolves to one person who has passkeys. Run the passkey with `options`.
>
> ```json theme={null}
> {
>   "data": {
>     "data": {
>       "options": {
>         "rpId": "botshield.ai",
>         "challenge": "Q2hhbGxlbmdl…",
>         "allowCredentials": [
>           { "id": "AbC1…", "type": "public-key", "transports": ["internal", "hybrid"] }
>         ],
>         "userVerification": "required",
>         "timeout": 60000
>       },
>       "challenge_id": "6f1d3c1e-8a2b-4c7d-9e0f-1a2b3c4d5e6f",
>       "expires_at": "2026-11-10T17:09:05.000Z",
>       "url": "https://app.botshield.ai/verify?request_id=req_4b1f…",
>       "setup_url": "https://app.botshield.ai/app/account/passkey"
>     }
>   }
> }
> ```
>
> | Field | Description |
> | - | - |
> | `options` | WebAuthn request options. Pass them to the platform unchanged. `allowCredentials` lists only this person's passkeys. |
> | `challenge_id` | Send it back with the answer. The challenge lasts 5 minutes and works once. |
> | `url` | The hand-off to BotShield for this request. See [Open BotShield](https://docs.botshield.ai/native/open-botshield.md). |
> | `setup_url` | Where the person adds a BotShield passkey. |

> [tab] fallback
>
> Nobody to run a passkey for. Show your panel and offer the hand-off.
>
> ```json theme={null}
> {
>   "data": {
>     "data": {
>       "fallback": "app",
>       "reason": "not_linked",
>       "url": "https://app.botshield.ai/verify?request_id=req_4b1f…",
>       "setup_url": "https://app.botshield.ai/app/account/passkey"
>     }
>   }
> }
> ```
>
> | `reason` | Meaning |
> | - | - |
> | `not_linked` | The request has no linked person: no `partner_user_ref`, or this account isn't secured with you in this environment. |
> | `no_passkey` | The linked person has no BotShield passkey yet. |

> [tab] closed
>
> The request can't be completed. Start a new one if the person still wants to go on.
>
> ```json theme={null}
> { "data": { "data": { "outcome": "expired", "code": "request_expired" } } }
> ```
>
> | `outcome` | `code` |
> | - | - |
> | `failed` | `unknown_request`, `request_closed` |
> | `expired` | `request_expired` |

An unexpected server error arrives as `.data.error` with `statusCode: 500`. Treat it like `fallback`.

**3. Run the passkey on the device**

Call the platform's passkey API with `rpId`, `challenge` and `allowCredentials` from `options`, and user verification **required**. On iOS, ask for **immediately available credentials only**, so a device without the passkey fails fast instead of showing Apple's "use a passkey from another device" sheet. See the [examples](#examples).

**4. Send the answer back**

Call `POST /request/passkey-complete` with the request, the challenge and the platform's answer as WebAuthn JSON (`AuthenticationResponseJSON`).

```bash theme={null}
curl -s -X POST https://api.botshield.ai/operations/request/passkey-complete \
  -H "Content-Type: application/json" \
  -d '{
    "request_id": "req_4b1f0c6e2a9d4e7f8a3b5c6d7e8f9a0b",
    "challenge_id": "6f1d3c1e-8a2b-4c7d-9e0f-1a2b3c4d5e6f",
    "response": {
      "id": "AbC1…",
      "rawId": "AbC1…",
      "type": "public-key",
      "response": {
        "clientDataJSON": "eyJ0eXBlIjoid2ViYXV0aG4uZ2V0Ii…",
        "authenticatorData": "SZYN5YgOjGh0NBcPZHZgW4…",
        "signature": "MEUCIQ…",
        "userHandle": "…"
      },
      "clientExtensionResults": {}
    }
  }'
```

The answer is `{ outcome, code? }` and nothing else:

```json theme={null}
{ "data": { "data": { "outcome": "verified" } } }
```

BotShield checks that the challenge belongs to this request, that the request is still open and still points at the same person, that the passkey belongs to that person, and that the person unlocked it. Then it completes the request the same way the BotShield app does: the token is minted and your webhook is sent.

**5. Finish on your server**

Tell your server the app is done, and let the server decide. Read the result by webhook or `GET /verification/status`, and [check the token](https://docs.botshield.ai/gate/verify-on-your-server.md) before you let the action through. Never let the app's `verified` alone unlock anything.

## Outcomes and what to show

`passkey-complete` returns one of four outcomes. `code` is optional and never identifies anyone.

| `outcome` | `code` | What happened | What to show |
| - | - | - | - |
| `verified` | — | The person confirmed. | Carry on. Your server confirms by webhook or status. |
| `wrong_identity` | — | The passkey picked belongs to a different BotShield ID. Nothing changed. | "That passkey isn't for this account." Offer **Use the BotShield app instead**. |
| `expired` | `challenge_expired` | The challenge was used or ran past 5 minutes. | "That took too long." Run `passkey-options` again for the same request. |
| `expired` | `request_expired` | The request ran past 5 minutes. | Offer to start again: create a new request on your server. |
| `failed` | `link_changed` | The account was unlinked or relinked mid-way. | Treat as `not_linked`. |
| `failed` | `assertion_invalid`, `user_not_verified` | The answer didn't check out, or the person didn't unlock the passkey. | Offer the hand-off. |
| `failed` | `unknown_request`, `request_closed`, `completion_failed`, or a completion refusal | The request can't complete. | Offer the hand-off, or start again. |

The device can fail before BotShield answers. These come from the platform, not from BotShield:

| On the device | What to show |
| - | - |
| **No passkey here.** None of the person's passkeys is on this device. iOS with immediately available credentials only, Capacitor `NO_CREDENTIAL`. | A panel: "Didn't find your BotShield passkey?" with **Set up a passkey in BotShield** (opens `setup_url`) and **Use the BotShield app instead** (opens `url`). |
| **The person cancelled** the sheet. Capacitor `CANCELED`. | The same two choices. Keep your own button as the retry. |
| **App not associated** with `botshield.ai`. Capacitor `DOMAIN_NOT_ASSOCIATED`. | **Use the BotShield app instead**. Check your [registration](https://docs.botshield.ai/native/register-your-app.md). |
| **Passkeys not supported** here. Capacitor `NOT_SUPPORTED`. | **Use the BotShield app instead**. |

Rules that keep it from dead-ending:

* **Every panel keeps a way through:** **Use the BotShield app instead**, which opens `url`. See [Open BotShield](https://docs.botshield.ai/native/open-botshield.md).
* **`not_linked`:** offer [Link with BotShield ID](https://docs.botshield.ai/secured-accounts/overview.md), then the inline passkey works next time.
* **Coming back from `setup_url`:** call `passkey-options` again for the same request. When it returns `options`, the passkey exists now and your button can retry. When it still returns `fallback`, show "You came back without a passkey." and turn the button into **Open BotShield**.
* **Never hardcode BotShield URLs.** Use `url` and `setup_url` from the response.
* **Never log** the challenge, the assertion, the request ID or the credential ID.

> [!NOTE]
>
> Until the next BotShield app release, `setup_url` opens BotShield on the web, even when the BotShield app is installed. A passkey made there works inline straight away. After that release it opens the BotShield app.

## What the person sees

On iPhone, Apple's sheet reads *Sign in to "Your App" with your passkey for "BotShield ID •49ac"*. Apple writes the sentence. BotShield sets only the passkey's name, `BotShield ID •` and a short suffix, so no email or name appears in your app.

Put the **Powered by BotShield ID** lockup under the button that runs the passkey, and nowhere else. Don't draw a separate BotShield screen around the sheet.

## Examples

> [tab] Capacitor (iOS)
>
> Uses `@capawesome/capacitor-passkeys`. The stock plugin can't ask iOS for immediately available credentials only, so a device without the passkey shows Apple's other-device sheet first. Treat `CANCELED` the same as `NO_CREDENTIAL`.
>
> ```typescript theme={null}
> import { Passkeys } from "@capawesome/capacitor-passkeys";
>
> const API = "https://api.botshield.ai/operations";
>
> async function op(name: string, body: object) {
>   const res = await fetch(`${API}/request/${name}`, {
>     method: "POST",
>     headers: { "Content-Type": "application/json" },
>     body: JSON.stringify(body),
>   });
>   const json = await res.json();
>   return json?.data?.data ?? null; // every answer is HTTP 200
> }
>
> type Result =
>   | { kind: "verified" }
>   | { kind: "panel"; panel: string; url?: string; setupUrl?: string };
>
> export async function confirmInline(requestId: string): Promise<Result> {
>   const o = await op("passkey-options", { request_id: requestId });
>   if (!o) return { kind: "panel", panel: "failed" };
>   if (o.fallback) return { kind: "panel", panel: o.reason, url: o.url, setupUrl: o.setup_url };
>   if (!o.options) return { kind: "panel", panel: o.outcome }; // expired or failed
>
>   let answer;
>   try {
>     answer = await Passkeys.getPasskey({
>       rpId: o.options.rpId,
>       challenge: o.options.challenge,
>       allowCredentials: o.options.allowCredentials,
>       timeout: o.options.timeout,
>       userVerification: "required",
>     });
>   } catch (e: any) {
>     const code = e?.code;
>     const panel =
>       code === "NO_CREDENTIAL" || code === "CANCELED" ? "no_credential"
>       : code === "DOMAIN_NOT_ASSOCIATED" || code === "NOT_SUPPORTED" ? "unsupported"
>       : "failed";
>     return { kind: "panel", panel, url: o.url, setupUrl: o.setup_url };
>   }
>
>   const c = await op("passkey-complete", {
>     request_id: requestId,
>     challenge_id: o.challenge_id,
>     response: { ...answer, clientExtensionResults: {} },
>   });
>   if (c?.outcome === "verified") return { kind: "verified" };
>   const panel = c?.code === "link_changed" ? "not_linked" : c?.outcome ?? "failed";
>   return { kind: "panel", panel, url: o.url, setupUrl: o.setup_url };
> }
> ```
>
> Don't `await` or return the `Passkeys` plugin object itself from an async function. Capacitor plugin proxies look like promises, and the call can hang.

> [tab] Swift (iOS 16+)
>
> ```swift theme={null}
> import AuthenticationServices
>
> struct PasskeyOptions: Decodable {
>   struct Options: Decodable {
>     struct Cred: Decodable { let id: String }
>     let rpId: String
>     let challenge: String
>     let allowCredentials: [Cred]?
>   }
>   let options: Options?
>   let challenge_id: String?
>   let url: String?
>   let setup_url: String?
>   let fallback: String?
>   let reason: String?
>   let outcome: String?
> }
>
> func b64urlDecode(_ s: String) -> Data? {
>   var b = s.replacingOccurrences(of: "-", with: "+").replacingOccurrences(of: "_", with: "/")
>   while b.count % 4 != 0 { b += "=" }
>   return Data(base64Encoded: b)
> }
>
> func b64url(_ d: Data) -> String {
>   d.base64EncodedString()
>     .replacingOccurrences(of: "+", with: "-")
>     .replacingOccurrences(of: "/", with: "_")
>     .replacingOccurrences(of: "=", with: "")
> }
>
> final class BotShieldInlinePasskey: NSObject,
>   ASAuthorizationControllerDelegate, ASAuthorizationControllerPresentationContextProviding {
>
>   private var continuation: CheckedContinuation<[String: Any], Error>?
>   private let anchor: ASPresentationAnchor
>   init(anchor: ASPresentationAnchor) { self.anchor = anchor }
>
>   /// Runs the passkey with BotShield's options. Returns AuthenticationResponseJSON.
>   func assert(_ o: PasskeyOptions.Options) async throws -> [String: Any] {
>     let provider = ASAuthorizationPlatformPublicKeyCredentialProvider(relyingPartyIdentifier: o.rpId)
>     let request = provider.createCredentialAssertionRequest(challenge: b64urlDecode(o.challenge)!)
>     request.userVerificationPreference = .required
>     request.allowedCredentials = (o.allowCredentials ?? []).compactMap {
>       b64urlDecode($0.id).map(ASAuthorizationPlatformPublicKeyCredentialDescriptor.init(credentialID:))
>     }
>     let controller = ASAuthorizationController(authorizationRequests: [request])
>     controller.delegate = self
>     controller.presentationContextProvider = self
>     return try await withCheckedThrowingContinuation { c in
>       continuation = c
>       // Fail fast, with no other-device sheet, when this device holds none of the passkeys.
>       controller.performRequests(options: .preferImmediatelyAvailableCredentials)
>     }
>   }
>
>   func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor { anchor }
>
>   func authorizationController(controller: ASAuthorizationController,
>                                didCompleteWithAuthorization auth: ASAuthorization) {
>     guard let a = auth.credential as? ASAuthorizationPlatformPublicKeyCredentialAssertion else {
>       continuation?.resume(throwing: ASAuthorizationError(.failed)); return
>     }
>     let id = b64url(a.credentialID)
>     continuation?.resume(returning: [
>       "id": id,
>       "rawId": id,
>       "type": "public-key",
>       "authenticatorAttachment": "platform",
>       "response": [
>         "clientDataJSON": b64url(a.rawClientDataJSON),
>         "authenticatorData": b64url(a.rawAuthenticatorData),
>         "signature": b64url(a.signature),
>         "userHandle": b64url(a.userID),
>       ],
>       "clientExtensionResults": [String: Any](),
>     ])
>   }
>
>   func authorizationController(controller: ASAuthorizationController,
>                                didCompleteWithError error: Error) {
>     // .canceled covers both "the person cancelled" and, with
>     // preferImmediatelyAvailableCredentials, "no passkey on this device".
>     // Show the same panel for both.
>     continuation?.resume(throwing: error)
>   }
> }
> ```
>
> POST the returned dictionary as `response` to `passkey-complete`, with `request_id` and `challenge_id`.

> [tab] Web (widget)
>
> On a web page, the passkey runs in BotShield's own frame. Add `betas="inline-passkey"` to the widget. Your page never calls these endpoints.
>
> ```html theme={null}
> <script src="https://cdn.botshield.ai/sdk.js"></script>
> <botshield-verify
>   site-key="pk_live_…"
>   scope="checkout"
>   scan-mode="modal"
>   betas="inline-passkey"
> ></botshield-verify>
> ```
>
> Desktop only. Every failure falls back to the QR code. See [Inline passkey (Beta)](https://docs.botshield.ai/gate/web-component.md#inline-passkey-beta).

## Android

Android apps can be registered (see [Register your app](https://docs.botshield.ai/native/register-your-app.md)), and BotShield accepts the app's signing origin once the registration is approved. No Android app has run the inline passkey end to end yet. Until one has, use the [Open BotShield](https://docs.botshield.ai/native/open-botshield.md) hand-off on Android. An app that tries early and isn't set up gets `failed` with `assertion_invalid`.

On Android, use Credential Manager's `GetPublicKeyCredentialOption` with the `options` JSON, and map `NoCredentialException` to the "no passkey here" panel.

## Privacy

* `passkey-options` returns WebAuthn options for the request's person, the hand-off links, and nothing about who the person is. Credential IDs in `allowCredentials` are opaque to you; don't store them.
* `passkey-complete` returns only `outcome` and `code`. It never returns a token, a BotShield ID or a session.
* The unlock happens on the person's phone. BotShield receives a signed answer from the passkey, never a face or a fingerprint. See [Privacy boundary](https://docs.botshield.ai/concepts/privacy-boundary.md).


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