Skip to main content

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.

In this tutorial, you’ll use the GBG GO Journey API v2 to build an identity and age verification application that dynamically renders form fields based on your journey configuration. Instead of hardcoding forms, the app fetches interaction data from the API and updates the UI at runtime, so when you add or remove module elements in the Journey builder, the frontend updates automatically.

What this tutorial covers

By the end, you’ll have a fully functional demo application that:
  • Starts a verification journey via the GO API.
  • Fetches interaction data to dynamically render form pages and fields.
  • Collects identity information (name, date of birth, address, documents) and submits it for verification.
  • Polls for journey results and displays the outcome.
Once verification is complete, you can view journey details and outcomes in the Investigation portal.

Prerequisites

  • Access to the GO dashboard with create permissions for adding modules to the journey.
  • For developers:
    • Node.js v18 or higher installed on your local machine.
    • A working knowledge of TypeScript and React.
    • GBG GO API credentials. For more details, refer to Quickstart for developers.

Get started

To get started with this tutorial:
  1. Add the document identity and age verification modules to the journey, as shown in the image below:
doc and age module img
The journey also includes evaluation steps that route the flow based on module outcomes.
When adding the modules to your journey, ensure you select the v2 version of each module variant. For example, select Document Classification v2 rather than the original Document Classification variant. The v2 variants are designed to work with the latest API and have improved features and performance.
For instructions on how to add modules to a journey, refer to Configure modules.
  1. Click Publish to Preview.
Modules come with default outcomes, which are used in this tutorial. You can configure different outcomes for each module. To learn more, refer to How to configure module outcomes in GO.
  1. Navigate to Dashboard in the Journey builder to get your resource ID and version for integrating the journey with the API.
Next, set up the development environment.

Module outcomes in this tutorial

Before moving forward, it’s important to understand the module outcomes used in this tutorial:
  • Document Classification module: The Document Classification module performs a range of checks based on the captured document. Results include document type, issuer, and relevant checks to aid decisions made prior to extraction and field-level authentication. Below are the default outcomes for the Document Classification module. These outcomes can be configured in the Journey builder based on your use case and business needs.
    • Document Classified
    • Document NOT Classified
  • Document Extraction module: The Document Extraction module extracts all fields from the previously classified document. You can specify which data fields are important, and the module reports whether each selected field was successfully or unsuccessfully extracted. Below are the default outcomes for the Document Extraction module. These outcomes can be configured in the Journey builder based on your use case and business needs.
    • Extraction Successful
    • Extraction Unsuccessful
    • ERROR
  • Age Verification module: Age Verification for US. Below are the default outcomes for the Age Verification module. These outcomes can be configured in the Journey builder based on your use case and business needs.
    • Of Age
    • Under Age
    • Match Restricted
    • Can Not Confirm Age
    • ERROR
These are default outcomes and can be modified as needed. For the purpose of this tutorial, leave the default outcomes as-is and use them to set up evaluation decisions in the journey builder. For more information on configuring module outcomes, refer to How to configure module outcomes in GO.

Set up journey decisions

This section guides you through configuring journey decisions based on module outcomes. Follow the steps below to add evaluation decisions to your journey:
  1. Click the + icon at the bottom of the last module in your journey.
  2. Navigate to Routing > Evaluation.
  3. Click Add to journey. This step adds the evaluation node to your journey.
  4. Click the evaluation node. The right sidebar shows the evaluation configuration options.
  5. Click Configure decisions to open the decision configuration panel. Based on module outcomes, set up the following decisions:
  • If Document Classification is not Document Classified → Reject.
  • If Document Extraction is Extraction Unsuccessful → Manual review.
  • If all modules return successful outcomes → Accept
doc and age module img

Set up the development environment

This section walks you through creating the project and installing dependencies.

Step 1: Create a new Next.js project

Open your terminal and run the following commands to scaffold a Next.js project with TypeScript and Tailwind CSS:
Bash
npx create-next-app@latest gbg-identity-demo --typescript --tailwind --app --src-dir --import-alias "@/*"
cd gbg-identity-demo

Step 2: Install dependencies

Install lucide-react for icons and @tailwindcss/forms for styled form elements:
Bash
npm install lucide-react @tailwindcss/forms

Step 3: Configure Tailwind CSS and add global styles

Define custom colours, fonts, and plugins directly in your CSS file using @theme and @plugin directives. Replace the contents of src/app/globals.css with the following:
Css
@import url("https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700;1,9..40,400&family=Instrument+Sans:wght@400;500;600;700&display=swap");
@import "tailwindcss";

@plugin "@tailwindcss/forms";

@theme {
  --color-gbg-50: #f0f4ff;
  --color-gbg-100: #dbe4ff;
  --color-gbg-200: #bac8ff;
  --color-gbg-300: #91a7ff;
  --color-gbg-400: #748ffc;
  --color-gbg-500: #5c7cfa;
  --color-gbg-600: #4c6ef5;
  --color-gbg-700: #4263eb;
  --color-gbg-800: #3b5bdb;
  --color-gbg-900: #364fc7;
  --color-gbg-950: #1e3a8a;

  --font-sans: "DM Sans", system-ui, sans-serif;
  --font-display: "Instrument Sans", system-ui, sans-serif;
}

@layer base {
  body {
    @apply bg-slate-50 text-slate-900 antialiased;
  }
}

@layer components {
  .input-field {
    @apply block w-full rounded-lg border border-slate-300 bg-white px-4 py-3
           text-sm text-slate-900 shadow-sm transition-all duration-200
           placeholder:text-slate-400
           hover:border-slate-400
           focus:border-gbg-500 focus:ring-2 focus:ring-gbg-500/20 focus:outline-none;
  }

  .input-label {
    @apply mb-1.5 block text-sm font-medium text-slate-700;
  }

  .input-error {
    @apply mt-1 text-xs text-red-600;
  }

  .btn-primary {
    @apply inline-flex items-center justify-center gap-2 rounded-lg bg-gbg-700 px-6 py-3
           text-sm font-semibold text-white shadow-sm transition-all duration-200
           hover:bg-gbg-800 hover:shadow-md
           focus:ring-2 focus:ring-gbg-500/40 focus:ring-offset-2 focus:outline-none
           disabled:cursor-not-allowed disabled:opacity-50;
  }

  .btn-secondary {
    @apply inline-flex items-center justify-center gap-2 rounded-lg border border-slate-300
           bg-white px-6 py-3 text-sm font-semibold text-slate-700 shadow-sm
           transition-all duration-200
           hover:bg-slate-50 hover:border-slate-400
           focus:ring-2 focus:ring-slate-500/20 focus:ring-offset-2 focus:outline-none;
  }
}
There are three key parts to this file:
  • @import "tailwindcss": Loads all of Tailwind’s base, component, and utility styles.
  • @plugin "@tailwindcss/forms": Registers the forms plugin, which provides styled reset styles for form elements like inputs and selects.
  • @theme { ... }: Defines custom design tokens. The --color-gbg-* variables create the GBG brand colour palette, making utility classes like bg-gbg-700 and text-gbg-500 available throughout the app. The --font-sans and --font-display variables set up custom font families for body text and headings.
The input-field, input-label, btn-primary, and btn-secondary classes in the @layer components block are reusable component styles used throughout the frontend. Defining them here keeps the component markup clean.

Step 4: Update the root layout

Replace the contents of src/app/layout.tsx with the following. This imports the global styles and sets up the page metadata:
Typescript
// src/app/layout.tsx
import type { Metadata } from "next";
import "./globals.css";

export const metadata: Metadata = {
  title: "Identity Verification | GBG GO",
  description:
    "Secure identity and age verification powered by GBG GO Journey API v2.",
};

export default function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    <html lang="en">
      <body className="min-h-screen bg-slate-50">{children}</body>
    </html>
  );
}
The globals.css import loads the Google Fonts and Tailwind styles. The metadata export sets the page title and description that appear in the browser tab and search results.

Step 5: Set up environment variables

Create a .env.local file in your project root to store your API credentials:
Variables
GBG_API_BASE_URL=your_api_base_url
GBG_PING_URL=your_ping_federate_token_endpoint
GBG_CLIENT_ID=your_client_id
GBG_CLIENT_SECRET=your_client_secret
GBG_USERNAME=your_username
GBG_PASSWORD=your_password
GBG_JOURNEY_RESOURCE_ID=your_resource_id@latest
Replace the placeholder values with your actual GBG GO API credentials and journey details.

Architecture overview

Before writing code, here’s how the app is structured. Understanding the architecture helps you see how each piece fits together.
src/
├── app/
│   ├── api/
│   │   ├── auth/
│   │   │   ├── token.ts          # OAuth token management
│   │   │   └── route.ts          # Auth test endpoint
│   │   └── journey/
│   │       ├── start/route.ts    # Start a journey
│   │       ├── fetch/route.ts    # Fetch current interaction
│   │       ├── submit/route.ts   # Submit form data
│   │       └── state/route.ts    # Get journey results
│   ├── page.tsx                  # Entry point
│   ├── layout.tsx                # Root layout
│   └── globals.css               # Global styles
├── components/
│   ├── VerificationForm.tsx      # Main form orchestrator
│   ├── StepIndicator.tsx         # Progress bar
│   ├── CardRenderer.tsx          # Dynamic card-to-field resolver
│   └── DynamicField.tsx          # Individual field renderer
└── lib/
    ├── types.ts                  # TypeScript interfaces
    ├── api.ts                    # Client-side API functions
    ├── domain-elements.ts        # Field registry and card mapping
    ├── domain-mapper.ts          # Form values → API payload
    └── validation.ts             # Per-page validation
The backend API routes act as a secure proxy between the browser and the GBG GO API. The frontend never sees your credentials. On the frontend, the domain element registry is the key architectural concept. It maps API card IDs to field definitions, so the form builds itself from the interaction data.

Start the journey

The first API call in the verification flow is starting a journey. This creates a new journey instance on the GBG platform and returns an instanceId that you use for subsequent requests.

Create the project folders

Create the directories for the API routes:
Bash
mkdir -p src/app/api/auth src/app/api/journey/start src/app/api/journey/fetch src/app/api/journey/submit src/app/api/journey/state

Create the authentication token manager

All GBG API calls require a bearer token. Create src/app/api/auth/token.ts to handle OAuth 2.0 token retrieval with in-memory caching:
Typescript

// src/app/api/auth/token.ts

// In-memory token cache (good enough for a single-server demo)
let cachedToken: { token: string; expiresAt: number } | null = null;

export async function getCustomerToken(): Promise<string> {
  // Return cached token if still valid (with 60s buffer)
  if (cachedToken && Date.now() < cachedToken.expiresAt - 60_000) {
    return cachedToken.token;
  }

  const res = await fetch(process.env.GBG_PING_URL!, {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams({
      client_id: process.env.GBG_CLIENT_ID!,
      client_secret: process.env.GBG_CLIENT_SECRET!,
      grant_type: "password",
      username: process.env.GBG_USERNAME!,
      password: process.env.GBG_PASSWORD!,
    }),
  });

  if (!res.ok) {
    const error = await res.text();
    throw new Error(`Authentication failed: ${res.status}${error}`);
  }

  const data = await res.json();

  cachedToken = {
    token: data.access_token,
    expiresAt: Date.now() + data.expires_in * 1000,
  };

  return cachedToken.token;
}
This function uses the OAuth 2.0 Resource Owner Password Grant to exchange your credentials for an access token. The token is cached in memory with a 60-second buffer before expiry, so subsequent API calls reuse the same token without hitting the auth server on every request.

Create the start journey route

Create src/app/api/journey/start/route.ts. This route receives a resourceId from the frontend and calls the GBG journey/start endpoint. There are three things to note in the request body:
  • resourceId: The journey hash and version you copied from the Journey Builder dashboard, for example abc123...@latest.
  • context.config.delivery: "api": This tells GBG you are consuming the journey via the API rather than a hosted web flow.
  • context.subject.documents (optional): When a documentImage is provided, it is included as a prefilled document using the side1Image field. This is used later in the submit flow. The document is uploaded at journey start time so the platform can begin processing it early.
Typescript

// src/app/api/journey/start/route.ts
import { NextRequest, NextResponse } from "next/server";
import { getCustomerToken } from "../../auth/token";

export async function POST(req: NextRequest) {
  try {
    const { resourceId, documentImage } = await req.json();
    const token = await getCustomerToken();

    // Build subject context — include document if provided (prefill mode)
    const subject: Record<string, unknown> = {};
    if (documentImage) {
      // Strip data URI prefix to get raw base64
      const raw = documentImage.replace(/^data:[^;]+;base64,/, "");
      subject.documents = [{ side1Image: raw }];
    }

    const res = await fetch(
      `${process.env.GBG_API_BASE_URL}/journey/start`,
      {
        method: "POST",
        headers: {
          Authorization: `Bearer ${token}`,
          "Content-Type": "application/json",
        },
        body: JSON.stringify({
          resourceId,
          context: {
            config: { delivery: "api" },
            subject,
          },
        }),
        cache: "no-store",
      },
    );

    const text = await res.text();

    let data: unknown;
    try {
      data = JSON.parse(text);
    } catch {
      return NextResponse.json(
        { error: `GBG returned non-JSON (${res.status}): ${text.slice(0, 200)}` },
        { status: 502 },
      );
    }

    if (!res.ok) {
      return NextResponse.json(data, { status: res.status });
    }

    return NextResponse.json(data);
  } catch (error) {
    return NextResponse.json(
      { error: (error as Error).message },
      { status: 500 },
    );
  }
}
The response returns an instanceId, a unique identifier for this journey session. The frontend uses this instanceId to fetch the current interaction and submit user data as they complete the form.

Fetch interactions

After starting a journey, the GBG platform may take a moment to prepare the first interaction. An interaction is the set of form pages, cards, and fields that the platform needs you to collect from the user. You fetch this data and use it to build the form UI dynamically.

Create the fetch interaction route

Create src/app/api/journey/fetch/route.ts:
Typescript
// src/app/api/journey/fetch/route.ts
import { NextRequest, NextResponse } from "next/server";
import { getCustomerToken } from "../../auth/token";

export async function POST(req: NextRequest) {
  try {
    const { instanceId } = await req.json();
    const token = await getCustomerToken();

    const res = await fetch(
      `${process.env.GBG_API_BASE_URL}/journey/interaction/fetch`,
      {
        method: "POST",
        headers: {
          Authorization: `Bearer ${token}`,
          "Content-Type": "application/json",
        },
        body: JSON.stringify({ instanceId }),
        cache: "no-store",
      },
    );

    const data = await res.json();
    if (!res.ok) {
      return NextResponse.json(data, { status: res.status });
    }

    return NextResponse.json(data);
  } catch (error) {
    return NextResponse.json(
      { error: (error as Error).message },
      { status: 500 },
    );
  }
}
This route sends the instanceId to the GBG journey/interaction/fetch endpoint and returns the interaction data. The response includes:
  • interactionId: Identifies this specific interaction — required when submitting data.
  • interaction.resource.data.pages: An array of pages, each containing cards that map to domain elements (for example, NameCard, DateOfBirthCard, AddressCard).
  • interaction.collects: An array specifying which domain elements are required and which are optional.
  • outstanding: Domain elements that still need to be collected.
Because the platform might need a moment to prepare the interaction after the journey starts, the frontend polls this endpoint until data is available.

Submit the interaction

When the user completes the final page and clicks Submit, the app needs to send all collected data through the /journey/interaction/submit endpoint for module processing.

Create the submit interaction route

Create src/app/api/journey/submit/route.ts:
Typescript
// src/app/api/journey/submit/route.ts
import { NextRequest, NextResponse } from "next/server";
import { getCustomerToken } from "../../auth/token";

export async function POST(req: NextRequest) {
  try {
    const body = await req.json();
    const token = await getCustomerToken();

    // Extract private document image field from client
    const docImage: string | undefined = body._documentImage;
    delete body._documentImage;

    // If document image provided, then add it in the correct GBG format
    if (docImage) {
      const rawBase64 = docImage.replace(/^data:[^;]+;base64,/, "");
      body.context.subject.documents = [
        {
          side1Image: rawBase64,
          side2Image: "",
          type: "Primary",
        },
      ];
    }

    const res = await fetch(
      `${process.env.GBG_API_BASE_URL}/journey/interaction/submit`,
      {
        method: "POST",
        headers: {
          Authorization: `Bearer ${token}`,
          "Content-Type": "application/json",
        },
        body: JSON.stringify(body),
        cache: "no-store",
      },
    );

    const text = await res.text();

    let data: unknown;
    try {
      data = JSON.parse(text);
    } catch {
      return NextResponse.json(
        { error: `GBG returned non-JSON (${res.status}): ${text.slice(0, 500)}` },
        { status: 502 },
      );
    }

    if (!res.ok) {
      return NextResponse.json(data, { status: res.status });
    }

    return NextResponse.json(data);
  } catch (error) {
    return NextResponse.json(
      { error: (error as Error).message },
      { status: 500 },
    );
  }
}
This route handles the complete submission payload and forwards it to the GBG journey/interaction/submit endpoint. There are two important things this route does:
  • Document handling: The frontend sends the document image as a private _documentImage field. The route strips this field from the payload and reformats it into the structure the GBG API expects: context.subject.documents[] with side1Image (the base64 image), side2Image (empty string), and type set to "Primary". This separation keeps the frontend simple while ensuring the API receives the correct format.
  • Payload forwarding: The remaining payload contains three parts assembled by the VerificationForm component:
    • instanceId and interactionId: Identifies which journey instance and interaction this submission belongs to.
    • participants: An array of domain element IDs that were collected, for example [{ domainElementId: "FullName" }, { domainElementId: "PrimaryDocument" }]. This tells the API which elements you are providing data for.
    • context.subject: The structured identity data such as names, date of birth, address, phone, and email fields in the format the API expects.

Fetch journey results

After submitting the interaction, the GBG platform processes the data through the verification modules configured in the journey. This processing happens asynchronously, so the app polls for the final result.

Create the journey state route

Create src/app/api/journey/state/route.ts:
Typescript
// src/app/api/journey/state/route.ts
import { NextRequest, NextResponse } from "next/server";
import { getCustomerToken } from "../../auth/token";

export async function POST(req: NextRequest) {
  try {
    const { instanceId } = await req.json();
    const token = await getCustomerToken();

    const res = await fetch(
      `${process.env.GBG_API_BASE_URL}/journey/state/fetch`,
      {
        method: "POST",
        headers: {
          Authorization: `Bearer ${token}`,
          "Content-Type": "application/json",
        },
        body: JSON.stringify({
          instanceId,
          filterKeys: ["/.*/"],
        }),
        cache: "no-store",
      },
    );

    const data = await res.json();
    if (!res.ok) {
      return NextResponse.json(data, { status: res.status });
    }

    // Normalize: GBG may return status at top level or nested under journey
    const status =
      data.status ?? data.journey?.status ?? "InProgress";

    return NextResponse.json({
      instanceId: data.instanceId ?? instanceId,
      status,
      metaData: data.metaData ?? data.journey?.metaData,
      context: data.context,
      data: data.data ?? data.journey?.data,
    });
  } catch (error) {
    return NextResponse.json(
      { error: (error as Error).message },
      { status: 500 },
    );
  }
}
The frontend polls this endpoint every 2 seconds after submission. Once status changes from "InProgress" to "Completed" or "Failed", the VerificationForm transitions to the result screen.

Building the frontend

In this section, you’ll build the components that render the dynamic form based on the interaction data. The key concept is the domain element registry, a mapping of GBG domain element IDs such as FullName, DateOfBirth, Address to field definitions that tell the app how to render them.

Create the frontend folders

Create the directories for the library modules and components:
Bash
mkdir -p src/lib src/components

TypeScript types

Create src/lib/types.ts to define the interfaces used across the app. These types model the GBG API responses and the dynamic form system:
Typescript
// src/lib/types.ts

// Interaction Fetch Response

export interface InteractionCard {
  id: string;
  config?: {
    secrets?: Record<string, { value: string }>;
  };
}

export interface InteractionPage {
  id: string;
  label: string;
  cards: InteractionCard[];
}

export interface InteractionResource {
  id: string;
  name: string;
  version: string;
  type: string;
  data: {
    pages: InteractionPage[];
  };
}

export interface CollectsItem {
  ref: string;
  spec: "required" | "optional";
}

export interface InteractionDetail {
  grId: string;
  resource: InteractionResource;
  collects: CollectsItem[];
  consumes: CollectsItem[];
}

export interface InteractionFetchResponse {
  instanceId: string;
  interactionId: string;
  journey: {
    status: "InProgress" | "Completed" | "Failed";
  };
  interaction: InteractionDetail;
  outstanding: string[];
}

// Journey Start

export interface JourneyStartResponse {
  instanceId: string;
  instanceUrl?: string;
  status?: string;
  message?: string;
}

// Interaction Submit

export interface SubmitPayload {
  instanceId: string;
  interactionId: string;
  participants: { domainElementId: string }[];
  context: {
    subject: Record<string, unknown>;
  };
}

export interface SubjectData {
  identity: {
    firstName?: string;
    middleNames?: string[];
    lastNames?: string[];
    dateOfBirth?: string;
    currentAddress?: AddressData;
    phones?: { type: string; number: string }[];
    emails?: { type: string; email: string }[];
    [key: string]: unknown;
  };
}

export interface AddressData {
  lines: string[];
  locality: string;
  dependentLocality?: string;
  postalCode: string;
  country: string;
}

// Journey State

export interface JourneyStateResponse {
  instanceId: string;
  status: "InProgress" | "Completed" | "Failed";
  metaData?: {
    createdTime: string;
    completedTime?: string;
  };
  context?: Record<string, unknown>;
  data?: Record<string, unknown>;
}

// Dynamic Form State

/** All form values stored as a flat string map keyed by field ID. */
export type FormValues = Record<string, string>;

// Dynamic Field Definitions

export interface FieldDef {
  id: string;
  label: string;
  type: "text" | "email" | "tel" | "date" | "select" | "file";
  placeholder?: string;
  autoComplete?: string;
  options?: { value: string; label: string }[];
  accept?: string;
  colSpan?: 1 | 2;
  hint?: string;
  alwaysOptional?: boolean;
  validate?: (value: string) => string | null;
}

/** Defines the fields a domain element collects and how to map them to the API payload. */
export interface DomainElementDef {
  id: string;
  fields: FieldDef[];
  requiredFields: string[];
  toSubject: (values: FormValues) => Partial<SubjectData["identity"]>;
}
The key Typescript types to understand are:
  • InteractionFetchResponse: The full response from the fetch interaction endpoint, containing pages, cards, and collection requirements.
  • DomainElementDef: Defines a domain element’s form fields, which are required, and how to transform the collected values into the API submission payload. This is the core of the dynamic form system.
  • FormValues: A flat Record<string, string> that stores all form field values keyed by field ID. This simplifies state management in the form components.

Client-side API layer

Create src/lib/api.ts to provide typed functions that the frontend components call.
Typescript
// src/lib/api.ts
import type {
  InteractionFetchResponse,
  JourneyStartResponse,
  JourneyStateResponse,
  SubmitPayload,
} from "./types";

class ApiError extends Error {
  constructor(
    message: string,
    public status: number,
    public body?: unknown,
  ) {
    super(message);
    this.name = "ApiError";
  }
}

async function request<T>(url: string, body?: unknown): Promise<T> {
  const res = await fetch(url, {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: body ? JSON.stringify(body) : undefined,
  });

  let data: unknown;
  try {
    data = await res.json();
  } catch {
    throw new ApiError(
      `Server returned ${res.status} with non-JSON response`,
      res.status,
    );
  }

  if (!res.ok) {
    const msg =
      (data as Record<string, string>)?.message ??
      (data as Record<string, string>)?.error ??
      "Request failed";
    throw new ApiError(msg, res.status, data);
  }

  return data as T;
}

// Public API

/** Starts a new journey and returns the instanceId. */
export async function startJourney(resourceId: string, documentImage?: string) {
  return request<JourneyStartResponse>("/api/journey/start", {
    resourceId,
    ...(documentImage ? { documentImage } : {}),
  });
}

/** Fetches the current interaction (pages, cards, outstanding elements). */
export async function fetchInteraction(instanceId: string) {
  return request<InteractionFetchResponse>("/api/journey/fetch", {
    instanceId,
  });
}

/** Submits collected domain element data for the current interaction. */
export async function submitInteraction(payload: SubmitPayload) {
  return request<{ status: string }>("/api/journey/submit", payload);
}

/** Fetches journey state (status, results). */
export async function fetchJourneyState(instanceId: string) {
  return request<JourneyStateResponse>("/api/journey/state", { instanceId });
}

/**
 * Polls interaction/fetch until an interaction appears or journey completes.
 * Uses 3-second intervals with a 60-second timeout.
 */
export async function pollForInteraction(
  instanceId: string,
  signal?: AbortSignal,
): Promise<InteractionFetchResponse> {
  const POLL_INTERVAL = 3000;
  const TIMEOUT = 60_000;
  const start = Date.now();

  while (Date.now() - start < TIMEOUT) {
    if (signal?.aborted) throw new Error("Polling aborted");

    try {
      const response = await fetchInteraction(instanceId);

      if (response.interactionId) return response;
      if (response.journey?.status !== "InProgress") return response;
    } catch (err) {
      if (err instanceof ApiError && err.status >= 500) {
        console.warn("Server error during poll, retrying...", err.message);
      } else {
        throw err;
      }
    }

    await new Promise((r) => setTimeout(r, POLL_INTERVAL));
  }

  throw new Error("Polling timed out waiting for interaction");
}
The request helper standardises error handling across all API calls. The pollForInteraction function is particularly important, after starting a journey, the GBG platform might take a few seconds to prepare the interaction. This function retries every 3 seconds for up to 60 seconds, tolerating transient 5xx errors during the startup window.

Domain element registry

The domain element registry is the heart of the dynamic form system. It defines what fields each card collects and how to map those values to the GBG API payload. Create src/lib/domain-elements.ts:
Typescript
// src/lib/domain-elements.ts
import type { DomainElementDef, FormValues } from "./types";

const COUNTRIES = [
  { value: "GB", label: "United Kingdom" },
  { value: "US", label: "United States" },
  { value: "AU", label: "Australia" },
  { value: "CA", label: "Canada" },
  { value: "DE", label: "Germany" },
  { value: "FR", label: "France" },
  { value: "NG", label: "Nigeria" },
  { value: "IN", label: "India" },
  { value: "ZA", label: "South Africa" },
  { value: "IE", label: "Ireland" },
];

/**
 * Registry of all known domain elements.
 *
 * Each entry defines:
 * - The form fields the element collects
 * - Which fields are required when the element is marked "required"
 * - How to map field values into the canonical subject payload
 *
 * To support a new domain element, add an entry here — no other
 * component changes needed.
 */
const DOMAIN_ELEMENTS: DomainElementDef[] = [
  {
    id: "FullName",
    requiredFields: ["firstName", "lastNames"],
    fields: [
      {
        id: "firstName",
        label: "First name",
        type: "text",
        placeholder: "Jane",
        autoComplete: "given-name",
      },
      {
        id: "middleNames",
        label: "Middle name(s)",
        type: "text",
        placeholder: "Marie",
        autoComplete: "additional-name",
        alwaysOptional: true,
        hint: "Separate multiple middle names with commas.",
      },
      {
        id: "lastNames",
        label: "Last name(s)",
        type: "text",
        placeholder: "Doe",
        autoComplete: "family-name",
        hint: "Separate multiple last names with commas.",
      },
    ],
    toSubject: (v: FormValues) => ({
      firstName: v.firstName?.trim(),
      lastNames: v.lastNames
        ?.split(",")
        .map((n) => n.trim())
        .filter(Boolean),
      ...(v.middleNames?.trim()
        ? {
            middleNames: v.middleNames
              .split(",")
              .map((n) => n.trim())
              .filter(Boolean),
          }
        : {}),
    }),
  },
  {
    id: "DateOfBirth",
    requiredFields: ["dateOfBirth"],
    fields: [
      {
        id: "dateOfBirth",
        label: "Date of birth",
        type: "date",
        autoComplete: "bday",
        hint: "Format: YYYY-MM-DD",
        validate: (value: string) => {
          if (!value) return null;
          const dob = new Date(value);
          if (isNaN(dob.getTime())) return "Enter a valid date.";
          if (dob > new Date()) return "Date of birth cannot be in the future.";
          return null;
        },
      },
    ],
    toSubject: (v: FormValues) => ({
      dateOfBirth: v.dateOfBirth,
    }),
  },
  {
    id: "PersonalEmail",
    requiredFields: ["email"],
    fields: [
      {
        id: "email",
        label: "Email address",
        type: "email",
        placeholder: "jane.doe@example.com",
        autoComplete: "email",
        validate: (value: string) => {
          if (value.trim() && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value))
            return "Enter a valid email address.";
          return null;
        },
      },
    ],
    toSubject: (v: FormValues) =>
      v.email?.trim()
        ? { emails: [{ type: "personal", email: v.email.trim() }] }
        : {},
  },
  {
    id: "MobilePhone",
    requiredFields: ["phone"],
    fields: [
      {
        id: "phone",
        label: "Mobile phone number",
        type: "tel",
        placeholder: "+44 7700 900000",
        autoComplete: "tel",
        validate: (value: string) => {
          if (value.trim() && value.replace(/\D/g, "").length < 7)
            return "Enter a valid phone number.";
          return null;
        },
      },
    ],
    toSubject: (v: FormValues) =>
      v.phone?.trim()
        ? { phones: [{ type: "mobile", number: v.phone.trim() }] }
        : {},
  },
  {
    id: "CurrentAddress",
    requiredFields: ["addressLine1", "locality", "postalCode", "country"],
    fields: [
      {
        id: "addressLine1",
        label: "Address line 1",
        type: "text",
        placeholder: "123 High Street",
        autoComplete: "address-line1",
      },
      {
        id: "addressLine2",
        label: "Address line 2",
        type: "text",
        placeholder: "Flat 2B",
        autoComplete: "address-line2",
        alwaysOptional: true,
      },
      {
        id: "locality",
        label: "City / Town",
        type: "text",
        placeholder: "London",
        autoComplete: "address-level2",
        colSpan: 1,
      },
      {
        id: "administrativeArea",
        label: "State / County",
        type: "text",
        placeholder: "CA",
        autoComplete: "address-level1",
        colSpan: 1,
        hint: "For US addresses, use the 2-letter state code (e.g. CA, NY).",
      },
      {
        id: "postalCode",
        label: "Postal code",
        type: "text",
        placeholder: "SW1A 1AA",
        autoComplete: "postal-code",
        colSpan: 1,
      },
      {
        id: "country",
        label: "Country",
        type: "select",
        autoComplete: "country",
        options: COUNTRIES,
        colSpan: 1,
      },
    ],
    toSubject: (v: FormValues) => ({
      currentAddress: {
        lines: [v.addressLine1, v.addressLine2]
          .map((l) => l?.trim())
          .filter(Boolean),
        locality: v.locality?.trim() ?? "",
        administrativeArea: v.administrativeArea?.trim() || undefined,
        postalCode: v.postalCode?.trim() ?? "",
        country: v.country?.trim() ?? "",
      },
    }),
  },
  {
    id: "PrimaryDocument",
    requiredFields: ["documentType", "documentImage"],
    fields: [
      {
        id: "documentType",
        label: "Document type",
        type: "select",
        options: [
          { value: "PASSPORT", label: "Passport" },
          { value: "DRIVING_LICENCE", label: "Driving licence" },
          { value: "NATIONAL_ID", label: "National ID card" },
        ],
      },
      {
        id: "documentImage",
        label: "Upload document image",
        type: "file",
        accept: "image/*,.pdf",
        hint: "Upload a clear photo or scan of your document.",
      },
    ],
    // PrimaryDocument data is handled by the submit API route, not here.
    // The route formats the document image into the structure the GBG API
    // expects (documents[{side1Image, side2Image, type: "Primary"}]).
    toSubject: () => ({}),
  },
];

/** Map from domain element ID to its definition. */
const REGISTRY = new Map(DOMAIN_ELEMENTS.map((d) => [d.id, d]));

/** Map from card ID to the domain element IDs it collects. */
const CARD_TO_DOMAIN: Record<string, string[]> = {
  NameCard: ["FullName"],
  DateOfBirthCard: ["DateOfBirth"],
  EmailCardPersonalEmail: ["PersonalEmail"],
  PhoneCardMobilePhone: ["MobilePhone"],
  AddressCard: ["CurrentAddress"],
  DocumentCard: ["PrimaryDocument"],
  ControlCard: [],
};

/** Look up a domain element definition. */
export function getDomainElement(id: string): DomainElementDef | undefined {
  return REGISTRY.get(id);
}

/** Resolve card IDs to domain element definitions. */
export function resolveCardElements(cardIds: string[]): DomainElementDef[] {
  const domainIds = cardIds.flatMap((id) => CARD_TO_DOMAIN[id] ?? []);
  return domainIds
    .map((id) => REGISTRY.get(id))
    .filter((d): d is DomainElementDef => d !== undefined);
}

/** Resolve card IDs to domain element ID strings. */
export function resolveDomainElementIds(cardIds: string[]): string[] {
  return cardIds.flatMap((id) => CARD_TO_DOMAIN[id] ?? []);
}
Each domain element definition has three key parts:
  • fields: An array of FieldDef objects that describe the form inputs, their types, labels, placeholders, validation rules, and grid layout hints. For example, colSpan: 1 renders the field at half-width in a two-column grid.
  • requiredFields: The field IDs that must be filled when the domain element’s collects spec is "required". Fields marked alwaysOptional: true such as middle names are excluded from this check.
  • toSubject: A function that transforms flat form values into the nested structure the GBG API expects. For example, the FullName element splits comma-separated last names into an array: "Doe, Smith" becomes ["Doe", "Smith"].
The CARD_TO_DOMAIN mapping at the bottom connects GBG card IDs like NameCard to domain element IDs like FullName. When the API returns a page with a NameCard, the registry resolves it to the FullName element, which knows exactly which fields to render. To support a new domain element, you add an entry to the registry, with no component changes needed.

Domain mapper

Create src/lib/domain-mapper.ts. This module transforms the flat FormValues map into the structured SubjectData payload that the GBG API expects:
Typescript
// src/lib/domain-mapper.ts
import type { FormValues } from "./types";
import { getDomainElement, resolveDomainElementIds } from "./domain-elements";

/**
 * Builds the canonical `context.subject` payload from dynamic form values.
 * Iterates over the collected domain elements and delegates to each
 * element's `toSubject` mapper defined in the registry.
 */
export function buildSubjectPayload(
  formValues: FormValues,
  domainElementIds: string[],
): Record<string, unknown> {
  const identity: Record<string, unknown> = {};

  for (const id of domainElementIds) {
    const def = getDomainElement(id);
    if (!def) continue;

    const partial = def.toSubject(formValues);
    Object.assign(identity, partial);
  }

  return { identity };
}

/**
 * Resolves which domain element IDs a list of card IDs collects.
 */
export function resolveDomainElements(cardIds: string[]): string[] {
  return resolveDomainElementIds(cardIds);
}
The buildSubjectPayload function iterates over all collected domain elements, calls each element’s toSubject mapper, and merges the results into a single identity object. The return type is Record<string, unknown> rather than a strict SubjectData because the submit route adds document data (documents[]) at the subject level separately. Each domain element only knows about its own fields, but the final payload contains data from all elements across all pages.

Form validation

Create src/lib/validation.ts. This module validates a single page of form values against the domain element definitions:
Typescript
// src/lib/validation.ts
import type { FormValues, CollectsItem } from "./types";
import { getDomainElement } from "./domain-elements";

type Errors = Record<string, string>;

/**
 * Validates form values for the domain elements on a given page.
 * Uses the field definitions from the registry — no hardcoded field checks.
 * Returns an error map (field → message). Empty map = valid.
 */
export function validatePage(
  formValues: FormValues,
  domainElementIds: string[],
  collects: CollectsItem[],
): Errors {
  const errors: Errors = {};
  const specMap = new Map(collects.map((c) => [c.ref, c.spec]));

  for (const elementId of domainElementIds) {
    const def = getDomainElement(elementId);
    if (!def) continue;

    const isRequired = specMap.get(elementId) === "required";

    for (const field of def.fields) {
      const value = formValues[field.id] ?? "";

      // Check required
      if (
        isRequired &&
        !field.alwaysOptional &&
        def.requiredFields.includes(field.id) &&
        !value.trim()
      ) {
        errors[field.id] = `${field.label} is required.`;
        continue;
      }

      // Run field-level validator
      if (field.validate && value) {
        const err = field.validate(value);
        if (err) errors[field.id] = err;
      }
    }
  }

  return errors;
}
Validation is driven entirely by the registry. For each domain element on the current page, the function checks whether empty required fields are missing and runs any custom validate function defined on the field (for example, the date-of-birth validator that rejects future dates). The collects array from the API response determines whether each element is required or optional.

DynamicField component

Create src/components/DynamicField.tsx. This component renders a single form field based on its FieldDef type:
Typescript
// src/components/DynamicField.tsx
"use client";

import { useRef } from "react";
import { Upload, X } from "lucide-react";
import type { FieldDef } from "@/lib/types";

interface DynamicFieldProps {
  field: FieldDef;
  value: string;
  error?: string;
  required: boolean;
  onChange: (value: string) => void;
}

export function DynamicField({
  field,
  value,
  error,
  required,
  onChange,
}: DynamicFieldProps) {
  const errorClass = error ? "!border-red-400 !ring-red-500/20" : "";
  const fileInputRef = useRef<HTMLInputElement>(null);

  const labelSuffix = required ? (
    <span className="text-red-500">*</span>
  ) : (
    <span className="ml-1 text-xs font-normal text-slate-400">optional</span>
  );

  // File input
  if (field.type === "file") {
    const hasFile = !!value;

    const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
      const file = e.target.files?.[0];
      if (!file) return;

      const reader = new FileReader();
      reader.onload = () => {
        onChange(reader.result as string);
      };
      reader.readAsDataURL(file);
    };

    const handleClear = () => {
      onChange("");
      if (fileInputRef.current) fileInputRef.current.value = "";
    };

    return (
      <div>
        <label className="input-label">
          {field.label} {labelSuffix}
        </label>

        <input
          ref={fileInputRef}
          type="file"
          accept={field.accept}
          onChange={handleFileChange}
          className="hidden"
          id={field.id}
        />

        {hasFile ? (
          <div className="flex items-center gap-3 rounded-lg border border-slate-200 bg-slate-50 px-4 py-3">
            <div className="flex h-10 w-10 items-center justify-center rounded-lg bg-gbg-100">
              <Upload className="h-5 w-5 text-gbg-700" />
            </div>
            <div className="flex-1 min-w-0">
              <p className="text-sm font-medium text-slate-700 truncate">
                Document uploaded
              </p>
              <p className="text-xs text-slate-500">
                Click the X to remove and re-upload
              </p>
            </div>
            <button
              type="button"
              onClick={handleClear}
              className="flex h-8 w-8 items-center justify-center rounded-full text-slate-400 hover:bg-slate-200 hover:text-slate-600 transition-colors"
            >
              <X className="h-4 w-4" />
            </button>
          </div>
        ) : (
          <button
            type="button"
            onClick={() => fileInputRef.current?.click()}
            className={`
              flex w-full items-center justify-center gap-2 rounded-lg border-2 border-dashed
              px-4 py-8 text-sm transition-colors
              ${error
                ? "border-red-300 bg-red-50 text-red-600 hover:border-red-400"
                : "border-slate-300 bg-slate-50 text-slate-500 hover:border-gbg-400 hover:bg-gbg-50 hover:text-gbg-700"
              }
            `}
          >
            <Upload className="h-5 w-5" />
            Click to upload your document
          </button>
        )}

        {error && <p className="input-error">{error}</p>}
        {field.hint && (
          <p className="mt-1 text-xs text-slate-400">{field.hint}</p>
        )}
      </div>
    );
  }

  // Select input
  if (field.type === "select") {
    return (
      <div>
        <label htmlFor={field.id} className="input-label">
          {field.label} {labelSuffix}
        </label>
        <select
          id={field.id}
          className={`input-field ${errorClass}`}
          value={value}
          onChange={(e) => onChange(e.target.value)}
          autoComplete={field.autoComplete}
        >
          <option value="">Select {field.label.toLowerCase()}</option>
          {field.options?.map((opt) => (
            <option key={opt.value} value={opt.value}>
              {opt.label}
            </option>
          ))}
        </select>
        {error && <p className="input-error">{error}</p>}
        {field.hint && (
          <p className="mt-1 text-xs text-slate-400">{field.hint}</p>
        )}
      </div>
    );
  }

  // Standard input (text, email, tel, date)
  return (
    <div>
      <label htmlFor={field.id} className="input-label">
        {field.label} {labelSuffix}
      </label>
      <input
        id={field.id}
        type={field.type}
        className={`input-field ${errorClass}`}
        placeholder={field.placeholder}
        value={value}
        onChange={(e) => onChange(e.target.value)}
        autoComplete={field.autoComplete}
        {...(field.type === "date"
          ? { max: new Date().toISOString().split("T")[0] }
          : {})}
      />
      {error && <p className="input-error">{error}</p>}
      {field.hint && (
        <p className="mt-1 text-xs text-slate-400">{field.hint}</p>
      )}
    </div>
  );
}
DynamicField handles four distinct field types:
  • File inputs: Uses a hidden <input type="file"> behind a styled upload button. When a file is selected, it reads the file as a base64 data URI using FileReader and stores the result as a string in FormValues. After upload, it shows a confirmation state with a clear button.
  • Select inputs: Renders a <select> dropdown populated from the options array defined in the field’s FieldDef.
  • Standard inputs: Renders <input> elements for text, email, tel, and date types. Date fields automatically set max to today’s date to prevent future dates.
  • Labels and errors: Every field shows a required indicator (*) or “optional” tag, and displays validation errors and hint text below the input.

CardRenderer component

Create src/components/CardRenderer.tsx. This component takes a single card from the interaction response and renders the appropriate form fields:
Typescript
// src/components/CardRenderer.tsx
"use client";

import type { InteractionCard, CollectsItem, FormValues } from "@/lib/types";
import { resolveCardElements } from "@/lib/domain-elements";
import { DynamicField } from "./DynamicField";

interface CardRendererProps {
  card: InteractionCard;
  formValues: FormValues;
  collects: CollectsItem[];
  onChange: (fieldId: string, value: string) => void;
  errors: Record<string, string>;
}

/**
 * Dynamically renders form fields for a card based on the domain elements
 * it maps to. No hardcoded card components — everything is driven by the
 * domain element registry in domain-elements.ts.
 */
export function CardRenderer({
  card,
  formValues,
  collects,
  onChange,
  errors,
}: CardRendererProps) {
  // ControlCard is navigation-only — skip it
  if (card.id === "ControlCard") return null;

  const elements = resolveCardElements([card.id]);

  if (elements.length === 0) {
    return (
      <div className="rounded-lg border border-amber-200 bg-amber-50 p-4">
        <p className="text-sm text-amber-800">
          Unknown card type:{" "}
          <code className="rounded bg-amber-100 px-1.5 py-0.5 font-mono text-xs">
            {card.id}
          </code>
        </p>
        <p className="mt-1 text-xs text-amber-600">
          This card is not yet supported in this frontend.
        </p>
      </div>
    );
  }

  // Build a spec lookup from collects
  const specMap = new Map(collects.map((c) => [c.ref, c.spec]));

  return (
    <div className="space-y-6">
      {elements.map((element) => {
        const elementRequired = specMap.get(element.id) === "required";
        const hasGridFields = element.fields.some(
          (f) => f.colSpan === 1,
        );

        return (
          <div key={element.id}>
            {hasGridFields ? (
              <div className="grid grid-cols-2 gap-4">
                {element.fields.map((field) => {
                  const isRequired =
                    !field.alwaysOptional &&
                    elementRequired &&
                    element.requiredFields.includes(field.id);

                  const wrapper =
                    field.colSpan === 1 ? (
                      <div key={field.id}>
                        <DynamicField
                          field={field}
                          value={formValues[field.id] ?? ""}
                          error={errors[field.id]}
                          required={isRequired}
                          onChange={(v) => onChange(field.id, v)}
                        />
                      </div>
                    ) : (
                      <div key={field.id} className="col-span-2">
                        <DynamicField
                          field={field}
                          value={formValues[field.id] ?? ""}
                          error={errors[field.id]}
                          required={isRequired}
                          onChange={(v) => onChange(field.id, v)}
                        />
                      </div>
                    );

                  return wrapper;
                })}
              </div>
            ) : (
              <div className="space-y-5">
                {element.fields.map((field) => {
                  const isRequired =
                    !field.alwaysOptional &&
                    elementRequired &&
                    element.requiredFields.includes(field.id);

                  return (
                    <DynamicField
                      key={field.id}
                      field={field}
                      value={formValues[field.id] ?? ""}
                      error={errors[field.id]}
                      required={isRequired}
                      onChange={(v) => onChange(field.id, v)}
                    />
                  );
                })}
              </div>
            )}
          </div>
        );
      })}
    </div>
  );
}
The CardRenderer is the bridge between the API response and the form UI. Here’s its rendering logic:
  • Skip navigation cards: ControlCard is a navigation-only card with no fields to collect, so it returns null.
  • Resolve elements: It calls resolveCardElements with the card ID to look up the matching domain element definitions from the registry. If no match is found, it renders a warning banner. This makes it safe to add new card types in the Journey Builder before updating the frontend.
  • Determine layout: If any field in the element has colSpan: 1, it uses a two-column CSS grid. Fields with colSpan: 1 take half the width, while fields with colSpan: 2 (or no colSpan) span the full width. This is how the address form renders City and County side by side.
  • Determine required state: Each field’s required state is computed from three sources:
    • The collects spec from the API
    • The element’s requiredFields list
    • The field’s alwaysOptional flag

StepIndicator component

Create src/components/StepIndicator.tsx. This component renders a progress bar showing which page the user is on:
Typescript
// src/components/StepIndicator.tsx
"use client";

import { Check } from "lucide-react";
import type { InteractionPage } from "@/lib/types";

interface StepIndicatorProps {
  pages: InteractionPage[];
  currentIndex: number;
}

export function StepIndicator({ pages, currentIndex }: StepIndicatorProps) {
  return (
    <nav aria-label="Verification steps" className="mb-10">
      <ol className="flex items-center">
        {pages.map((page, idx) => {
          const isCompleted = idx < currentIndex;
          const isCurrent = idx === currentIndex;

          return (
            <li
              key={page.id}
              className="flex items-center last:flex-none flex-1"
            >
              {/* Step circle */}
              <div className="flex flex-col items-center">
                <div
                  className={`
                    flex h-10 w-10 items-center justify-center rounded-full
                    text-sm font-semibold transition-all duration-300
                    ${
                      isCompleted
                        ? "bg-gbg-700 text-white shadow-md shadow-gbg-700/30"
                        : isCurrent
                          ? "bg-gbg-100 text-gbg-800 ring-2 ring-gbg-500 ring-offset-2"
                          : "bg-slate-100 text-slate-400"
                    }
                  `}
                >
                  {isCompleted ? (
                    <Check className="h-5 w-5" strokeWidth={3} />
                  ) : (
                    idx + 1
                  )}
                </div>
                <span
                  className={`
                    mt-2 text-xs font-medium text-center max-w-[100px]
                    ${isCurrent ? "text-gbg-800" : isCompleted ? "text-slate-600" : "text-slate-400"}
                  `}
                >
                  {page.label}
                </span>
              </div>

              {/* Connector line (not after last item) */}
              {idx < pages.length - 1 && (
                <div
                  className={`
                    mx-3 mt-[-1.5rem] h-0.5 flex-1 rounded-full transition-colors duration-300
                    ${idx < currentIndex ? "bg-gbg-700" : "bg-slate-200"}
                  `}
                />
              )}
            </li>
          );
        })}
      </ol>
    </nav>
  );
}
The StepIndicator renders numbered circles connected by lines. It receives the pages array directly from the interaction response, so the number of steps automatically matches however many pages the journey has. Completed steps show a checkmark icon, the current step has a highlighted ring, and future steps are greyed out.

VerificationForm component

Create src/components/VerificationForm.tsx. This is the main orchestrator that manages the entire verification workflow:
Typescript
// src/components/VerificationForm.tsx
"use client";

import { useState, useCallback } from "react";
import {
  Shield,
  Loader2,
  CheckCircle2,
  XCircle,
  ArrowRight,
  ArrowLeft,
  RotateCcw,
} from "lucide-react";
import type { InteractionFetchResponse, FormValues } from "@/lib/types";
import {
  startJourney,
  pollForInteraction,
  fetchJourneyState,
} from "@/lib/api";
import { buildSubjectPayload, resolveDomainElements } from "@/lib/domain-mapper";
import { validatePage } from "@/lib/validation";
import { StepIndicator } from "./StepIndicator";
import { CardRenderer } from "./CardRenderer";

type Stage = "idle" | "starting" | "form" | "submitting" | "polling" | "result";

interface Props {
  resourceId: string;
}

export function VerificationForm({ resourceId }: Props) {
  const [stage, setStage] = useState<Stage>("idle");
  const [interaction, setInteraction] = useState<InteractionFetchResponse | null>(null);
  const [instanceId, setInstanceId] = useState<string | null>(null);
  const [journeyResult, setJourneyResult] = useState<{
    status: string;
    data?: Record<string, unknown>;
  } | null>(null);

  const [formValues, setFormValues] = useState<FormValues>({});
  const [currentPageIndex, setCurrentPageIndex] = useState(0);
  const [errors, setErrors] = useState<Record<string, string>>({});
  const [globalError, setGlobalError] = useState<string | null>(null);

  const pages = interaction?.interaction?.resource?.data?.pages ?? [];
  const currentPage = pages[currentPageIndex];
  const isLastPage = currentPageIndex === pages.length - 1;

  const handleFieldChange = useCallback((fieldId: string, value: string) => {
    setFormValues((prev) => ({ ...prev, [fieldId]: value }));
    setErrors((prev) => {
      if (!prev[fieldId]) return prev;
      const next = { ...prev };
      delete next[fieldId];
      return next;
    });
  }, []);

  /** Start a discovery journey to get the form schema. */
  const handleBegin = async () => {
    setStage("starting");
    setGlobalError(null);
    try {
      const startRes = await startJourney(resourceId);
      if (!startRes?.instanceId) throw new Error("No instance ID returned.");
      setInstanceId(startRes.instanceId);
      const interactionData = await pollForInteraction(startRes.instanceId);
      if (!interactionData?.interaction?.resource?.data?.pages?.length) {
        throw new Error("No interaction pages returned.");
      }
      setInteraction(interactionData);
      setCurrentPageIndex(0);
      setStage("form");
    } catch (err) {
      setGlobalError((err as Error).message);
      setStage("idle");
    }
  };

  /** Validate current page. Advance, or submit on the last page. */
  const handleNext = async () => {
    if (!interaction || !currentPage) return;
    const cardIds = currentPage.cards.map((c) => c.id);
    const pageDomainElements = resolveDomainElements(cardIds);
    const pageErrors = validatePage(
      formValues, pageDomainElements, interaction.interaction.collects,
    );
    if (Object.keys(pageErrors).length > 0) { setErrors(pageErrors); return; }
    if (!isLastPage) { setCurrentPageIndex((prev) => prev + 1); setErrors({}); return; }
    await handleSubmitAll();
  };

  /**
   * Submit flow:
   * 1. Start a new journey
   * 2. Fetch the interaction
   * 3. Build the payload with all domain elements and the document image
   * 4. Submit via the API route (which formats the document correctly)
   * 5. Poll for completion
   */
  const handleSubmitAll = async () => {
    setStage("submitting");
    setGlobalError(null);

    try {
      // 1. Start journey
      const startRes = await startJourney(resourceId);
      if (!startRes?.instanceId) throw new Error("No instance ID returned.");
      const rid = startRes.instanceId;

      // 2. Fetch interaction
      const inter = await pollForInteraction(rid);
      if (!inter?.interactionId) throw new Error("No interaction returned.");

      // 3. Build payload with ALL domain elements
      const allCardIds = pages.flatMap((p) => p.cards.map((c) => c.id));
      const allDomainElements = resolveDomainElements(allCardIds);
      const participants = allDomainElements.map((id) => ({ domainElementId: id }));
      const subject = buildSubjectPayload(formValues, allDomainElements);
      const docImage = formValues.documentImage || "";

      // 4. Submit — pass document as _documentImage for server-side formatting
      const res = await fetch("/api/journey/submit", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          instanceId: rid,
          interactionId: inter.interactionId,
          participants,
          context: { subject },
          _documentImage: docImage,
        }),
      });

      const data = await res.json();
      if (!res.ok) {
        const msg = data?.message ?? data?.error ?? data?.errors?.[0]?.problem ?? "Submit failed";
        throw new Error(msg);
      }

      // 5. Poll for completion
      setStage("polling");

      const MAX_POLLS = 60;
      const INTERVAL = 3000;

      for (let i = 0; i < MAX_POLLS; i++) {
        await new Promise((r) => setTimeout(r, INTERVAL));
        const state = await fetchJourneyState(rid);

        if (state.status === "Completed" || state.status === "Failed") {
          setJourneyResult({ status: state.status, data: state.data ?? undefined });
          setStage("result");
          return;
        }
      }

      setJourneyResult({ status: "InProgress" });
      setStage("result");
    } catch (err) {
      setGlobalError((err as Error).message);
      setStage("form");
    }
  };

  const handleBack = () => {
    if (currentPageIndex > 0) { setCurrentPageIndex((prev) => prev - 1); setErrors({}); }
  };

  const handleReset = () => {
    setStage("idle"); setInteraction(null); setInstanceId(null);
    setJourneyResult(null); setFormValues({}); setCurrentPageIndex(0);
    setErrors({}); setGlobalError(null);
  };

  return (
    <div className="mx-auto w-full max-w-2xl">
      <div className="mb-8 text-center">
        <div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-2xl bg-gbg-100">
          <Shield className="h-7 w-7 text-gbg-700" />
        </div>
        <h1 className="font-display text-3xl font-bold tracking-tight text-slate-900">
          Identity verification
        </h1>
        <p className="mt-2 text-sm text-slate-500">Powered by GBG GOsecure, fast, and compliant.</p>
      </div>

      {globalError && (
        <div className="mb-6 rounded-lg border border-red-200 bg-red-50 px-4 py-3">
          <div className="flex items-start gap-3">
            <XCircle className="mt-0.5 h-5 w-5 shrink-0 text-red-500" />
            <div>
              <p className="text-sm font-medium text-red-800">Something went wrong</p>
              <p className="mt-0.5 text-sm text-red-600">{globalError}</p>
            </div>
          </div>
        </div>
      )}

      {stage === "idle" && (
        <div className="rounded-2xl border border-slate-200 bg-white p-8 shadow-sm">
          <div className="text-center">
            <p className="mb-6 text-sm text-slate-600">Click below to begin.</p>
            <button onClick={handleBegin} className="btn-primary">
              Begin verification <ArrowRight className="h-4 w-4" />
            </button>
          </div>
        </div>
      )}

      {stage === "starting" && (
        <div className="rounded-2xl border border-slate-200 bg-white p-12 shadow-sm">
          <div className="flex flex-col items-center gap-4">
            <Loader2 className="h-10 w-10 animate-spin text-gbg-600" />
            <p className="font-medium text-slate-700">Starting your journey...</p>
          </div>
        </div>
      )}

      {stage === "form" && interaction && currentPage && (
        <div className="rounded-2xl border border-slate-200 bg-white p-8 shadow-sm">
          <StepIndicator pages={pages} currentIndex={currentPageIndex} />
          <h2 className="mb-6 font-display text-xl font-semibold text-slate-900">{currentPage.label}</h2>
          <div className="space-y-6">
            {currentPage.cards.map((card) => (
              <CardRenderer key={card.id} card={card} formValues={formValues}
                collects={interaction.interaction.collects} onChange={handleFieldChange} errors={errors} />
            ))}
          </div>
          <div className="mt-8 flex items-center justify-between border-t border-slate-100 pt-6">
            <button onClick={handleBack} disabled={currentPageIndex === 0} className="btn-secondary disabled:invisible">
              <ArrowLeft className="h-4 w-4" /> Back
            </button>
            <button onClick={handleNext} className="btn-primary">
              {isLastPage ? "Submit" : "Continue"} <ArrowRight className="h-4 w-4" />
            </button>
          </div>
        </div>
      )}

      {(stage === "submitting" || stage === "polling") && (
        <div className="rounded-2xl border border-slate-200 bg-white p-12 shadow-sm">
          <div className="flex flex-col items-center gap-4">
            <Loader2 className="h-10 w-10 animate-spin text-gbg-600" />
            <div className="text-center">
              <p className="font-medium text-slate-700">
                {stage === "submitting" ? "Submitting your data..." : "Verifying your identity..."}
              </p>
              <p className="mt-1 text-sm text-slate-500">
                {stage === "submitting"
                  ? "Uploading document and sending information."
                  : "The platform is processing. This may take a moment."}
              </p>
            </div>
          </div>
        </div>
      )}

      {stage === "result" && journeyResult && (
        <div className="rounded-2xl border border-slate-200 bg-white p-8 shadow-sm">
          <div className="flex flex-col items-center text-center">
            {journeyResult.status === "Completed" ? (
              <>
                <div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-emerald-100">
                  <CheckCircle2 className="h-8 w-8 text-emerald-600" />
                </div>
                <h2 className="font-display text-2xl font-bold text-slate-900">Verification complete</h2>
                <p className="mt-2 text-sm text-slate-500">Your identity has been successfully verified.</p>
              </>
            ) : journeyResult.status === "Failed" ? (
              <>
                <div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-red-100">
                  <XCircle className="h-8 w-8 text-red-600" />
                </div>
                <h2 className="font-display text-2xl font-bold text-slate-900">Verification failed</h2>
                <p className="mt-2 text-sm text-slate-500">Unable to verify your identity.</p>
              </>
            ) : (
              <>
                <div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-amber-100">
                  <Loader2 className="h-8 w-8 text-amber-600" />
                </div>
                <h2 className="font-display text-2xl font-bold text-slate-900">Still processing</h2>
                <p className="mt-2 text-sm text-slate-500">Check back shortly.</p>
              </>
            )}
            <button onClick={handleReset} className="btn-secondary mt-6">
              <RotateCcw className="h-4 w-4" /> Start new verification
            </button>
          </div>
        </div>
      )}
    </div>
  );
}
The VerificationForm component manages the entire verification lifecycle as a state machine with six stages:
StageWhat happens
idleShows a “Begin verification” button.
startingCalls startJourney, then polls fetchInteraction until pages are ready.
formRenders the multi-page form with StepIndicator and CardRenderer.
submittingStarts a new journey, fetches the interaction, builds the API payload using buildSubjectPayload, and submits everything including the document image via the /api/journey/submit route. The document is passed as a private _documentImage field. The server-side route formats it into the structure the GBG API expects (documents[{side1Image, side2Image, type: "Primary"}]).
pollingPolls fetchJourneyState every 3 seconds (up to 60 attempts) waiting for the journey to complete.
resultShows a success, failure, or “still processing” screen with a reset button.
The handleNext function does double duty: on intermediate pages it validates and advances to the next page, and on the last page it triggers the full submission flow. Validation is per-page, only the domain elements present on the current page are validated, so users see errors immediately rather than at the end. The handleFieldChange callback clears field-level errors as soon as the user starts typing, providing instant feedback.

Entry point page

Finally, create src/app/page.tsx to render the VerificationForm with the journey resource ID from your environment variables:
Typescript
// src/app/page.tsx
import { VerificationForm } from "@/components/VerificationForm";

export default function Home() {
  const resourceId =
    process.env.GBG_JOURNEY_RESOURCE_ID ??
    "your_resource_id@latest";

  return (
    <main className="flex min-h-screen flex-col items-center justify-center px-4 py-16">
      <VerificationForm resourceId={resourceId} />

      <footer className="mt-12 text-center">
        <p className="text-xs text-slate-400">
          &copy; {new Date().getFullYear()} Powered by{" "}
          <a
            href="https://www.gbgplc.com"
            target="_blank"
            rel="noopener noreferrer"
            className="font-medium text-slate-500 hover:text-gbg-700 transition-colors"
          >
            GBG
          </a>
          . All rights reserved.
        </p>
      </footer>
    </main>
  );
}
This is a server component that reads the GBG_JOURNEY_RESOURCE_ID environment variable and passes it as a prop to the client-side VerificationForm.

Demo

Run the development server:
Bash
npm run dev
Navigate to localhost:3000. You should see the application running:
Demo app Pn
To test the verification flow:
  1. Click Begin verification. The app starts the journey and loads the form pages.
  2. Fill in the identity information across each step. The form fields and pages are designed and generated based on interaction fetch response. If you add or remove domain elements in the Journey builder, the form updates automatically.
  3. Click Submit on the final page. The app submits the data and polls for the result.
  4. Investigate the details of this session in the Investigation portal.
Investigate Pn
In the screenshot above, you can see that the decision is “Accept.” This means the uploaded document was successfully extracted and classified by GO modules, and the user’s age meets the legal requirement. The Investigation portal session details page shows the uploaded document image along with the extracted data fields. The full raw API response is also available for debugging.

Troubleshooting

This section covers common errors you may encounter when integrating with the GBG GO API and how to resolve them.

PrimaryDocument data is missing from context

This error occurs when the interaction submit payload doesn’t include document data in the format the API expects.
Json
{
  "code": "4002",
  "name": "MISSING_FIELD",
  "problem": "Required domain element 'PrimaryDocument' data is missing from context",
  "action": "Please provide valid data for 'PrimaryDocument'"
}
  • Fix: The PrimaryDocument domain element requires document data at context.subject.documents[] with three specific fields:
Json
{
  "documents": [
    {
      "side1Image": "<base64-encoded-image>",
      "side2Image": "",
      "type": "Primary"
    }
  ]
}
The submit route in this tutorial handles this formatting automatically via the _documentImage field.

Missing credentials for modules

This error occurs when a module in your journey requires API credentials or a licence key that hasn’t been configured in the GO platform.
Json
{
  "code": "4002",
  "name": "MISSING_FIELD",
  "problem": "Missing credentials for modules: DocumentAuthentication/doc_authentication_2",
  "action": "Please configure credentials for all modules used in the delivery"
}
  • Fix: If you don’t have credentials for that module, either remove it from the journey or contact your GBG account manager to have them provisioned. Re-publish the journey after making changes.