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

# Tutorial: Build a Web Identity Verification App

> Step-by-step guide to building a complete React app that runs a GBG Go identity verification journey with document capture.

This tutorial walks you through building a complete web app that runs a GBG Go identity verification journey inside an iframe. By the end, you will have a working React app — backed by a small Node companion server — that mints a journey URL, embeds the journey via the Web Bridge SDK, handles document-capture requests from the journey, and returns the captured image back to the iframe.

The app you build has two parts: a small Node/Express companion server that calls `@gbg/go-core` to mint journey sessions, and a React client that wraps the journey iframe in `BridgeHostProvider` and a capability handler. Total client code is around 150 lines.

> **Reference build:** The [gbg-go-web-reference](https://github.com/gbgplc/gbg-go-web-reference) repository is being staged under the `gbgplc` org and will host the complete source for this tutorial once GGO-14514 ships externally. Until then, follow this tutorial to build it step by step.

## Prerequisites

| Requirement       | Version          | Notes                                                                                                                                                    |
| ----------------- | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Node.js           | 18+              | For both the React build pipeline and the companion server                                                                                               |
| Browser           | Modern evergreen | ES2020 + camera support                                                                                                                                  |
| React             | 19.x             | Required by `@gbg/go-bridge-web-react`                                                                                                                   |
| TypeScript        | 5.x              | Optional but recommended; types ship with the SDK                                                                                                        |
| GBG Go API client | —                | Client ID and secret. Create one in the Account Management portal. See [Manage API clients](/docs/go-v2/platform/account-management/manage-api-clients). |

You also need the Web Bridge SDK. See [Getting Started → Installation](/docs/go-v2/developer-integration/sdks/web/getting-started#installation).

## Set up the backend

The browser does not call the GBG Go API directly. A companion server uses `@gbg/go-core` to mint journey sessions and returns the journey URL to the browser. This keeps API credentials server-side.

### 1. Create the server directory

```bash theme={null}
mkdir gbg-web-tutorial && cd gbg-web-tutorial
mkdir server && cd server
npm init -y
npm install express dotenv @gbg/go-core
npm install --save-dev @types/express @types/node tsx typescript
```

### 2. Configure credentials

Create a `.env` file:

```dotenv theme={null}
GO_CLIENT_ID=your-client-id
GO_CLIENT_SECRET=your-client-secret
GO_API_USERNAME=api-user@example.com
GO_API_PASSWORD=your-password

# 0 = EU, 1 = US, 2 = AU
GO_SERVER_IDX=0

# Default resource ID for journeys
GO_RESOURCE_ID=a4c68509c24789888eb466@latest

PORT=3000
```

Add `.env` to `.gitignore` immediately — these credentials are never to be committed or shipped to the browser.

### 3. Write the server

Create `server/index.ts`:

```typescript theme={null}
import "dotenv/config";
import express from "express";
import cors from "cors";
import { Go } from "@gbg/go-core";

const app = express();
app.use(express.json());
app.use(cors({ origin: "http://localhost:5173" }));

let go: Go | null = null;

async function getClient(): Promise<Go> {
  if (go) return go;
  const auth = await new Go().tokens.generate({
    clientId: process.env.GO_CLIENT_ID!,
    clientSecret: process.env.GO_CLIENT_SECRET!,
    username: process.env.GO_API_USERNAME!,
    password: process.env.GO_API_PASSWORD!,
    grantType: "password",
  });
  go = new Go({
    customerAccess: auth.accessToken,
    serverIdx: Number(process.env.GO_SERVER_IDX ?? 0),
  });
  return go;
}

app.get("/health", (_req, res) => res.json({ ok: true }));

app.post("/api/journey/start", async (req, res) => {
  try {
    const client = await getClient();
    const journey = await client.journeys.start({
      resourceId: req.body.resourceId ?? process.env.GO_RESOURCE_ID!,
      context: { subject: { identity: req.body.identity ?? {} } },
    });
    res.json({
      journeyUrl: journey.instanceUrl,
      instanceId: journey.instanceId,
    });
  } catch (err) {
    console.error("[journey.start failed]", err);
    res.status(500).json({ error: String(err) });
  }
});

app.listen(process.env.PORT ?? 3000, () => {
  console.log(`Server listening on http://localhost:${process.env.PORT ?? 3000}`);
});
```

Install `cors`:

```bash theme={null}
npm install cors @types/cors
```

### 4. Start the server

Add a script to `package.json`:

```json theme={null}
{
  "scripts": {
    "dev": "tsx watch index.ts"
  }
}
```

Then:

```bash theme={null}
npm run dev
```

You should see `Server listening on http://localhost:3000`.

### 5. Test the server

```bash theme={null}
curl http://localhost:3000/health
# {"ok":true}

curl -X POST http://localhost:3000/api/journey/start \
  -H "Content-Type: application/json" \
  -d '{}'
# {"journeyUrl":"https://...","instanceId":"..."}
```

Leave the server running. You'll need it for the rest of the tutorial.

## Create the React project

In a new terminal, from the `gbg-web-tutorial` directory:

```bash theme={null}
npm create vite@latest client -- --template react-ts
cd client
npm install
npm install @gbg/go-bridge-web @gbg/go-bridge-web-react
```

### Project structure

Replace `src/App.tsx`, and add a few new files. Target structure:

```
client/
├── src/
│   ├── App.tsx              # Routes between Setup and Journey
│   ├── SetupView.tsx        # Server URL + Start button
│   ├── JourneyView.tsx      # The bridge integration
│   ├── DocumentCaptureHandler.tsx
│   └── api.ts               # Calls the companion server
├── index.html
└── vite.config.ts
```

## App entry point

Replace `src/App.tsx`:

```tsx theme={null}
import { useState } from "react";
import { SetupView } from "./SetupView";
import { JourneyView } from "./JourneyView";

export default function App() {
  const [journeyUrl, setJourneyUrl] = useState<string | null>(null);

  if (journeyUrl) {
    return <JourneyView journeyUrl={journeyUrl} onExit={() => setJourneyUrl(null)} />;
  }
  return <SetupView onJourneyStarted={setJourneyUrl} />;
}
```

This is a simple two-view router driven by state. For production, swap in your real router (React Router, TanStack Router, Next.js routing, etc.).

## Configuration screen

Create `src/SetupView.tsx`:

```tsx theme={null}
import { useState } from "react";
import { startJourney } from "./api";

interface Props {
  onJourneyStarted: (url: string) => void;
}

export function SetupView({ onJourneyStarted }: Props) {
  const [serverUrl, setServerUrl] = useState("http://localhost:3000");
  const [resourceId, setResourceId] = useState("");
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);

  async function start() {
    setLoading(true);
    setError(null);
    try {
      const url = await startJourney(serverUrl, resourceId || undefined);
      onJourneyStarted(url);
    } catch (err) {
      setError(String(err));
    } finally {
      setLoading(false);
    }
  }

  return (
    <div style={{ padding: 24, maxWidth: 480 }}>
      <h1>GBG Go Reference</h1>
      <p>
        Configure the companion server connection and journey template, then click
        Start Journey.
      </p>

      <label>
        Server URL
        <input value={serverUrl} onChange={(e) => setServerUrl(e.target.value)} />
      </label>

      <label>
        Resource ID (optional)
        <input value={resourceId} onChange={(e) => setResourceId(e.target.value)} />
      </label>

      {error && <div role="alert" style={{ color: "crimson" }}>{error}</div>}

      <button onClick={start} disabled={loading || !serverUrl}>
        {loading ? "Starting…" : "Start Journey"}
      </button>
    </div>
  );
}
```

## Calling your backend

Create `src/api.ts`:

```typescript theme={null}
export async function startJourney(
  serverUrl: string,
  resourceId?: string,
): Promise<string> {
  const res = await fetch(`${serverUrl}/api/journey/start`, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify(resourceId ? { resourceId } : {}),
  });
  if (!res.ok) {
    const body = await res.text();
    throw new Error(`Server returned ${res.status}: ${body}`);
  }
  const data = await res.json();
  if (!data.journeyUrl) {
    throw new Error("Server did not return a journey URL");
  }
  return data.journeyUrl as string;
}
```

A single POST against the companion server's `/api/journey/start`. The server authenticates with GBG Go, mints a journey instance, and returns the URL. The browser never touches `@gbg/go-core` credentials.

## The bridge integration

This is the core of the integration. Create `src/JourneyView.tsx`:

```tsx theme={null}
import { BridgeHostProvider, useBridgeHostEvent } from "@gbg/go-bridge-web-react";
import { BridgeActions } from "@gbg/go-bridge-web";
import { useState, useEffect } from "react";
import { DocumentCaptureHandler } from "./DocumentCaptureHandler";

interface Props {
  journeyUrl: string;
  onExit: () => void;
}

export function JourneyView({ journeyUrl, onExit }: Props) {
  const [iframe, setIframe] = useState<HTMLIFrameElement | null>(null);
  const [permissionState, setPermissionState] = useState("prompt");

  useEffect(() => {
    if (!navigator.permissions) return;
    navigator.permissions
      .query({ name: "camera" as PermissionName })
      .then((status) => setPermissionState(status.state))
      .catch(() => undefined);
  }, []);

  return (
    <div style={{ display: "flex", flexDirection: "column", height: "100vh" }}>
      <header style={{ padding: 12, borderBottom: "1px solid #ddd" }}>
        <button onClick={onExit}>← Back</button>
        <span style={{ marginLeft: 12 }}>Journey</span>
      </header>

      <BridgeHostProvider
        iframe={iframe}
        allowedOrigins={[new URL(journeyUrl).origin]}
        hostVersion="1.0.0"
        capabilities={{
          "camera.document": {
            supported: true,
            version: "1.0",
            permissionState,
          },
        }}
      >
        <DocumentCaptureHandler />
        <CompletionListener onComplete={onExit} />

        <iframe
          ref={setIframe}
          src={journeyUrl}
          allow="camera"
          style={{ flex: 1, border: 0 }}
        />
      </BridgeHostProvider>
    </div>
  );
}

function CompletionListener({ onComplete }: { onComplete: () => void }) {
  useBridgeHostEvent(BridgeActions.JOURNEY_COMPLETED, onComplete);
  useBridgeHostEvent(BridgeActions.JOURNEY_FAILED, onComplete);
  useBridgeHostEvent(BridgeActions.JOURNEY_ABANDONED, onComplete);
  return null;
}
```

### How the bridge works

`BridgeHostProvider` is the React adapter around `BridgeHost`. It snapshots `allowedOrigins`, `hostVersion`, and `capabilities` when the iframe attaches, then instantiates the underlying host and exposes it to descendants through context.

The provider waits for `iframe` (a React state value) to transition from `null` to a real `HTMLIFrameElement`. That transition fires when the iframe element mounts and `ref={setIframe}` runs. Once attached, the bridge starts routing messages.

### The two-step capability pattern

The web SDK splits declaration and registration:

1. **Declaring** `camera.document` in the `capabilities` prop tells the journey "the host supports this" via `capability.query` responses.
2. **Registering** the `camera.document.capture` action handler (in `DocumentCaptureHandler`, below) attaches the function that actually runs when the journey invokes the action.

Both are required. Declaring without registering means the journey thinks the host can fulfil the capability but no handler ever runs. Registering without declaring means the handler is wired up but the journey's pre-check (`hasCapability`) returns `false` and it never invokes the action.

### Permission state

`navigator.permissions.query({ name: "camera" })` returns the current camera permission status. Passing it through `CapabilityInfo.permissionState` lets the journey prompt the user before triggering capture (e.g., "please grant camera access in browser settings").

The `permissionState` snapshot is captured at provider attach time. To apply a live change, force a remount or use `host.registerCustomCapability(...)` for the affected capability.

### Why useState, not useRef?

`BridgeHostProvider` watches `iframe` as part of its effect dependency array. Refs are not reactive — `useRef` never triggers a re-render, so the provider would never see the iframe attach. Always use `useState<HTMLIFrameElement | null>(null)` and pass `setIframe` as the ref callback.

## Handling capture requests

Create `src/DocumentCaptureHandler.tsx`:

```tsx theme={null}
import { useBridgeCapability } from "@gbg/go-bridge-web-react";
import { useRef, useState } from "react";
import type { BridgeResponder } from "@gbg/go-bridge-web";

export function DocumentCaptureHandler() {
  const responderRef = useRef<BridgeResponder | null>(null);
  const [active, setActive] = useState(false);

  useBridgeCapability("camera.document.capture", {
    handle: async (_request, responder) => {
      if (responderRef.current) {
        responder.error({
          code: "BUSY",
          message: "A capture is already active",
          recoverable: true,
        });
        return;
      }
      responderRef.current = responder;
      setActive(true);
    },
  });

  function complete(imageBase64: string, width: number, height: number) {
    responderRef.current?.success({
      imageBase64,
      imageWidth: width,
      imageHeight: height,
      mimeType: "image/jpeg",
    });
    responderRef.current = null;
    setActive(false);
  }

  function cancel() {
    responderRef.current?.cancelled("User dismissed");
    responderRef.current = null;
    setActive(false);
  }

  if (!active) return null;

  return (
    <div
      style={{
        position: "fixed",
        inset: 0,
        background: "rgba(0,0,0,0.95)",
        zIndex: 1000,
        display: "flex",
        flexDirection: "column",
        alignItems: "center",
        justifyContent: "center",
        color: "white",
      }}
    >
      <h2>Capture document</h2>
      <p>(Wire your camera surface here)</p>
      <input
        type="file"
        accept="image/*"
        capture="environment"
        onChange={(e) => {
          const file = e.target.files?.[0];
          if (!file) return;
          const reader = new FileReader();
          reader.onload = () => {
            const dataUrl = reader.result as string;
            const base64 = dataUrl.split(",")[1];
            const img = new Image();
            img.onload = () => complete(base64, img.width, img.height);
            img.src = dataUrl;
          };
          reader.readAsDataURL(file);
        }}
      />
      <button onClick={cancel}>Cancel</button>
    </div>
  );
}
```

This is a development capture surface. For production, swap the `<input type="file">` for a real camera component — `getUserMedia` with a `<video>` preview, a third-party capture SDK, or your existing design-system camera component. See [Capture Screens](/docs/go-v2/developer-integration/sdks/web/capture-screens) for richer patterns.

### Why a ref for the responder?

`BridgeResponder` instances are not React state — they're imperative handles for sending the response. Storing the active responder in a `useRef` lets the capture UI close over it without triggering re-renders on every message. When the capture finishes, the UI calls `responderRef.current?.success(...)` and clears the ref.

### Why a busy guard?

The web SDK doesn't enforce single-flight on raw `registerHandler` / `useBridgeCapability` — that protection is part of iOS/Android's typed slots. On web, do it yourself: if the responder ref is already set when a new request arrives, respond with a `BUSY` error.

## Run the app

1. Make sure the companion server is running (`npm run dev` in `server/`).
2. In a new terminal: `npm run dev` in `client/`. Vite starts at `http://localhost:5173`.
3. Open `http://localhost:5173`. Leave the server URL as `http://localhost:3000`.
4. Click **Start Journey**. The companion server mints a journey URL and the React app loads it in an iframe.
5. When the journey reaches a document capture step, the capture surface appears as a full-screen overlay.
6. Pick or capture a photo. The bridge sends the image back to the journey.

## Common pitfalls

A handful of issues catch most teams running the tutorial app for the first time. The notes below cover the recurring ones.

### CORS errors

If the companion server doesn't include `cors({ origin: "http://localhost:5173" })`, browser fetches fail with `No 'Access-Control-Allow-Origin' header`. The example server above includes CORS — match the client origin if you run Vite on a different port.

### Mixed content

If your client is HTTPS (e.g., behind a tunnel for mobile testing) and the journey URL is HTTP, the iframe will fail silently. Always use HTTPS in production. For local dev, `http://localhost` works in both directions because browsers treat localhost as a secure context.

### `@gbg/go-core` accidentally in the client bundle

If you import `@gbg/go-core` from any client-side file, your bundler may include it in the browser bundle — and the credentials with it. The Core SDK belongs in `server/` only. Verify by searching your built bundle for `@gbg/go-core` after `npm run build`.

### Capability query returns empty / handler never fires

The most common web integration mistake: forgetting either half of the two-step pattern. If `bridge.hasCapability("camera.document")` returns `false` in the journey, you forgot the `capabilities` prop. If the journey times out waiting for a response, you forgot to register the action handler.

### Provider's `host` stuck at `null`

`useRef<HTMLIFrameElement>(null)` does not trigger re-renders when the iframe element mounts. Use `useState<HTMLIFrameElement | null>(null)` and pass `setIframe` as the ref callback.

### Camera permission not delegated to the iframe

The iframe needs `allow="camera"` to use `getUserMedia` inside its own context. Without it, even with host permission granted, the iframe's calls fail.

## What's next

* **[Hello Journey](/docs/go-v2/developer-integration/sdks/web/examples/hello-journey)** — The smallest possible integration in one file.
* **[Two-Way Communication](/docs/go-v2/developer-integration/sdks/web/examples/two-way-communication)** — Send events from host to iframe; register a custom capability; observe the message log.
* **[Advanced Integration](/docs/go-v2/developer-integration/sdks/web/examples/advanced-integration)** — Pre-launch capability validation, lifecycle events, error states, and completion routing.
* **[Capture Screens](/docs/go-v2/developer-integration/sdks/web/capture-screens)** — Production-grade capture surfaces with `getUserMedia`, file pickers, and SDK swap points.
* **[Concepts](/docs/go-v2/developer-integration/sdks/web/concepts)** — Deeper dive into the architecture, message protocol, and capability system.
