diff --git a/e2e/recognize-app/src/index-callback-test.html b/e2e/recognize-app/src/index-callback-test.html
new file mode 100644
index 0000000000..050c0e96bb
--- /dev/null
+++ b/e2e/recognize-app/src/index-callback-test.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ Recognize Callback Test | Ping Identity JavaScript SDK
+
+
+
+
+
+
diff --git a/e2e/recognize-app/src/index-callback-test.ts b/e2e/recognize-app/src/index-callback-test.ts
new file mode 100644
index 0000000000..f32103a811
--- /dev/null
+++ b/e2e/recognize-app/src/index-callback-test.ts
@@ -0,0 +1,225 @@
+import {
+ callbackType,
+ journey,
+ NameCallback,
+ PasswordCallback,
+ PingOneRecognizeCallback,
+} from '@forgerock/journey-client';
+import { recognize } from '@forgerock/recognize';
+import './styles.css';
+
+const appEl = document.getElementById('app') as HTMLDivElement;
+appEl.style.cssText = 'display:flex;gap:1.5rem;align-items:flex-start;';
+
+const leftEl = document.createElement('div');
+leftEl.style.cssText = 'flex:0 0 400px;min-width:400px;';
+appEl.appendChild(leftEl);
+
+const rightEl = document.createElement('div');
+rightEl.style.cssText = 'flex:1;height:calc(100vh - 4rem);overflow-y:auto;';
+appEl.appendChild(rightEl);
+
+console.log('[build] recognize-app loaded');
+
+function promptConfig(): Promise<{ wellknown: string; journeyName: string }> {
+ return new Promise((resolve) => {
+ const form = document.createElement('form');
+ form.style.cssText = 'display:flex;flex-direction:column;gap:0.5rem;';
+ form.innerHTML = `
+
+
+
+ `;
+ leftEl.appendChild(form);
+ form.addEventListener('submit', (e) => {
+ e.preventDefault();
+ const wellknown = (form.querySelector('#wellknown') as HTMLInputElement).value.trim();
+ const journeyName = (form.querySelector('#journeyName') as HTMLInputElement).value.trim();
+ form.remove();
+ resolve({ wellknown, journeyName });
+ });
+ });
+}
+
+function log(msg: string) {
+ console.log(msg);
+ const p = document.createElement('p');
+ p.style.cssText = 'font-family:monospace;font-size:0.85rem;margin:2px 0;';
+ if (msg.startsWith('[error]')) p.style.color = 'crimson';
+ else if (msg.startsWith('[done]')) p.style.color = 'green';
+ else if (msg.startsWith('[recognize]')) p.style.color = '#2563eb';
+ else if (msg.startsWith('[step]')) p.style.color = '#7c3aed';
+ p.textContent = msg;
+ rightEl.appendChild(p);
+}
+
+function promptCredentials(): Promise<{ username: string; password: string }> {
+ return new Promise((resolve) => {
+ const form = document.createElement('form');
+ form.style.cssText = 'display:flex;flex-direction:column;gap:0.5rem;';
+ form.innerHTML = `
+
+
+
+ `;
+ leftEl.appendChild(form);
+ form.addEventListener('submit', (e) => {
+ e.preventDefault();
+ const username = (form.querySelector('#username') as HTMLInputElement).value;
+ const password = (form.querySelector('#password') as HTMLInputElement).value;
+ form.remove();
+ resolve({ username, password });
+ });
+ });
+}
+
+(async () => {
+ const { wellknown, journeyName } = await promptConfig();
+ log('[init] starting journey client...');
+ let journeyClient;
+ try {
+ journeyClient = await journey({ config: { serverConfig: { wellknown } } });
+ } catch (err) {
+ log(`[error] failed to init journey client: ${err}`);
+ return;
+ }
+
+ log('[init] starting journey...');
+ let step;
+ try {
+ step = await journeyClient.start({ journey: journeyName });
+ } catch (err) {
+ log(`[error] failed to start journey: ${err}`);
+ return;
+ }
+
+ while (step.type === 'Step') {
+ const recognizeCallback = step.callbacks.find(
+ (cb) => cb.getType() === callbackType.PingOneRecognizeCallback,
+ ) as PingOneRecognizeCallback | undefined;
+
+ if (recognizeCallback) {
+ log(`[step] got PingOneRecognizeCallback — op: ${recognizeCallback.getOperationType()}`);
+ log(`[config] ${JSON.stringify(recognizeCallback.getWebSDKConfig())}`);
+
+ const config = recognizeCallback.getWebSDKConfig();
+ const operationType = recognizeCallback.getOperationType();
+
+ const serviceURL = config.ws.url
+ .replace(/^wss:\/\//, 'https://')
+ .replace(/^ws:\/\//, 'http://');
+
+ log(`[options] webSDKOptions from server: ${JSON.stringify(recognizeCallback.getOptions())}`);
+
+ const client = recognize({
+ customer: recognizeCallback.getCustomerName(),
+ serviceURL,
+ ...(recognizeCallback.getTransactionData()
+ ? { transactionData: recognizeCallback.getTransactionData() }
+ : {}),
+ ...(recognizeCallback.getOptions() as Record),
+ });
+
+ await new Promise((resolve, reject) => {
+ client.subscribe({
+ next: (event) => {
+ log(
+ `[recognize] ${event.type}${'detail' in event ? ': ' + JSON.stringify(event.detail) : ''}`,
+ );
+ },
+ error: (err) => {
+ console.error(
+ '[recognize] raw error:',
+ err,
+ 'constructor:',
+ err?.constructor?.name,
+ 'instanceof RecognizeError:',
+ err instanceof Error,
+ );
+ log(
+ `[recognize] error: ${JSON.stringify(err)} — code:${err.error.code} — msg:${err.error.message} — constructor:${err?.constructor?.name}`,
+ );
+ recognizeCallback.setClientError(err.error.message);
+ recognizeCallback.setClientErrorCode(String(err.error.code));
+ resolve();
+ },
+ complete: (data) => {
+ log(`[recognize] complete — data: ${JSON.stringify(data)}`);
+ if (data.jwt) {
+ recognizeCallback.setSignedJwt(data.jwt);
+ try {
+ const payload = JSON.parse(atob(data.jwt.split('.')[1]));
+ if (payload.sub) {
+ log(`[recognize] recognizeId from JWT sub: ${payload.sub}`);
+ recognizeCallback.setRecognizeId(payload.sub);
+ }
+ } catch (e) {
+ log(`[recognize] could not parse JWT sub: ${e}`);
+ }
+ }
+ resolve();
+ },
+ });
+
+ const container = document.createElement('div');
+ leftEl.appendChild(container);
+
+ client
+ .init({
+ mode: 'mount',
+ container,
+ type: operationType === 'ENROLL' ? 'enroll' : 'auth',
+ username: recognizeCallback.getUsername(),
+ })
+ .then((err) => {
+ if (err) {
+ log(`[recognize] init error: ${err}`);
+ reject(err);
+ }
+ })
+ .catch((err) => {
+ log(`[recognize] init threw: ${err}`);
+ console.error('[recognize] init threw:', err);
+ reject(err);
+ });
+ });
+
+ client.dispose();
+ } else {
+ const hasName = step.callbacks.some((cb) => cb.getType() === callbackType.NameCallback);
+ const hasPassword = step.callbacks.some(
+ (cb) => cb.getType() === callbackType.PasswordCallback,
+ );
+
+ if (hasName || hasPassword) {
+ log('[step] credentials required');
+ const { username, password } = await promptCredentials();
+
+ if (hasName) {
+ const cb = step.callbacks.find(
+ (cb) => cb.getType() === callbackType.NameCallback,
+ ) as NameCallback;
+ cb.setName(username);
+ }
+ if (hasPassword) {
+ const cb = step.callbacks.find(
+ (cb) => cb.getType() === callbackType.PasswordCallback,
+ ) as PasswordCallback;
+ cb.setPassword(password);
+ }
+ } else {
+ const types = step.callbacks.map((cb) => cb.getType()).join(', ');
+ log(`[step] unhandled callbacks: [${types}]`);
+ break;
+ }
+ }
+
+ step = await journeyClient.next(step);
+ }
+
+ if (step.type === 'LoginSuccess') {
+ log(`[done] Login successful — session: ${step.getSessionToken() ?? 'none'}`);
+ } else if (step.type === 'LoginFailure') {
+ log(`[done] Login failed — ${step.payload.message}`);
+ }
+})();
diff --git a/packages/journey-client/src/lib/callbacks/factory.ts b/packages/journey-client/src/lib/callbacks/factory.ts
index 9eaae7d039..a8bea6d8b5 100644
--- a/packages/journey-client/src/lib/callbacks/factory.ts
+++ b/packages/journey-client/src/lib/callbacks/factory.ts
@@ -22,6 +22,7 @@ import { NameCallback } from './name-callback.js';
import { PasswordCallback } from './password-callback.js';
import { PingOneProtectEvaluationCallback } from './ping-protect-evaluation-callback.js';
import { PingOneProtectInitializeCallback } from './ping-protect-initialize-callback.js';
+import { PingOneRecognizeCallback } from './ping-one-recognize-callback.js';
import { PollingWaitCallback } from './polling-wait-callback.js';
import { ReCaptchaCallback } from './recaptcha-callback.js';
import { ReCaptchaEnterpriseCallback } from './recaptcha-enterprise-callback.js';
@@ -65,6 +66,8 @@ export function createCallback(callback: Callback): BaseCallback {
return new PingOneProtectEvaluationCallback(callback);
case callbackType.PingOneProtectInitializeCallback:
return new PingOneProtectInitializeCallback(callback);
+ case callbackType.PingOneRecognizeCallback:
+ return new PingOneRecognizeCallback(callback);
case callbackType.PollingWaitCallback:
return new PollingWaitCallback(callback);
case callbackType.ReCaptchaCallback:
diff --git a/packages/journey-client/src/lib/callbacks/ping-one-recognize-callback.ts b/packages/journey-client/src/lib/callbacks/ping-one-recognize-callback.ts
new file mode 100644
index 0000000000..0f249cd9ad
--- /dev/null
+++ b/packages/journey-client/src/lib/callbacks/ping-one-recognize-callback.ts
@@ -0,0 +1,79 @@
+/*
+ * Copyright (c) 2026 Ping Identity Corporation. All rights reserved.
+ *
+ * This software may be modified and distributed under the terms
+ * of the MIT license. See the LICENSE file for details.
+ */
+
+import type { Callback } from '@forgerock/sdk-types';
+
+import { BaseCallback } from './base-callback.js';
+
+export type PingOneRecognizeOperationType = 'ENROLL' | 'AUTHENTICATE';
+
+export interface PingOneRecognizeWebSDKConfig {
+ customer: { name: string };
+ transaction: { data: string };
+ username: string;
+ ws: { url: string };
+ [key: string]: unknown;
+}
+
+/**
+ * @class - Represents a callback used to perform PingOne Recognize (Keyless) biometric operations.
+ */
+export class PingOneRecognizeCallback extends BaseCallback {
+ constructor(public override payload: Callback) {
+ super(payload);
+ }
+
+ public getOperationType(): PingOneRecognizeOperationType {
+ return this.getOutputByName('operationType', 'AUTHENTICATE');
+ }
+
+ public getServiceURL(): string {
+ return this.getOutputByName('websocketURL', '');
+ }
+
+ public getCustomerName(): string {
+ return this.getOutputByName('customerName', '');
+ }
+
+ public getUsername(): string {
+ return this.getOutputByName('username', '');
+ }
+
+ public getTransactionData(): string {
+ return this.getOutputByName('transactionData', '');
+ }
+
+ public getOptions(): Record {
+ return this.getOutputByName>('webSDKOptions', {});
+ }
+
+ public getWebSDKConfig(): PingOneRecognizeWebSDKConfig {
+ return {
+ customer: { name: this.getCustomerName() },
+ transaction: { data: this.getTransactionData() },
+ username: this.getUsername(),
+ ws: { url: this.getServiceURL() },
+ ...this.getOptions(),
+ };
+ }
+
+ public setSignedJwt(jwt: string): void {
+ this.setInputValue(jwt, 'IDToken1signedJwt');
+ }
+
+ public setRecognizeId(recognizeId: string): void {
+ this.setInputValue(recognizeId, 'IDToken1recognizeId');
+ }
+
+ public setClientError(errorMessage: string): void {
+ this.setInputValue(errorMessage, 'IDToken1clientError');
+ }
+
+ public setClientErrorCode(errorCode: string): void {
+ this.setInputValue(errorCode, 'IDToken1clientErrorCode');
+ }
+}
diff --git a/packages/journey-client/src/types.ts b/packages/journey-client/src/types.ts
index e4802c9db3..0af88cc79d 100644
--- a/packages/journey-client/src/types.ts
+++ b/packages/journey-client/src/types.ts
@@ -47,6 +47,7 @@ export * from './lib/callbacks/name-callback.js';
export * from './lib/callbacks/password-callback.js';
export * from './lib/callbacks/ping-protect-evaluation-callback.js';
export * from './lib/callbacks/ping-protect-initialize-callback.js';
+export * from './lib/callbacks/ping-one-recognize-callback.js';
export * from './lib/callbacks/polling-wait-callback.js';
export * from './lib/callbacks/recaptcha-callback.js';
export * from './lib/callbacks/recaptcha-enterprise-callback.js';
diff --git a/packages/sdk-types/src/lib/am-callback.types.ts b/packages/sdk-types/src/lib/am-callback.types.ts
index 8ee87effdd..7fc8cd5c5c 100644
--- a/packages/sdk-types/src/lib/am-callback.types.ts
+++ b/packages/sdk-types/src/lib/am-callback.types.ts
@@ -20,6 +20,7 @@ export const callbackType = {
PasswordCallback: 'PasswordCallback',
PingOneProtectEvaluationCallback: 'PingOneProtectEvaluationCallback',
PingOneProtectInitializeCallback: 'PingOneProtectInitializeCallback',
+ PingOneRecognizeCallback: 'PingOneRecognizeCallback',
PollingWaitCallback: 'PollingWaitCallback',
ReCaptchaCallback: 'ReCaptchaCallback',
ReCaptchaEnterpriseCallback: 'ReCaptchaEnterpriseCallback',